From 734036c24a73d623188b74d3f14bc683af1e4ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Fri, 24 Jul 2026 21:14:38 +0200 Subject: [PATCH 1/4] Prioritize asset name and tag matches in search results Rank search results so that whole-word matches beat partial matches, a match in the item's name or an exact tag match is boosted above matches found only among the other tags, and 3D assets are slightly favored at comparable relevance. Remaining items keep their computed scores. --- newIDE/app/src/UI/Search/UseSearchItem.js | 142 ++++++++++++++++++++-- 1 file changed, 134 insertions(+), 8 deletions(-) diff --git a/newIDE/app/src/UI/Search/UseSearchItem.js b/newIDE/app/src/UI/Search/UseSearchItem.js index de32939166ba..a1d802d8bd7f 100644 --- a/newIDE/app/src/UI/Search/UseSearchItem.js +++ b/newIDE/app/src/UI/Search/UseSearchItem.js @@ -118,6 +118,50 @@ export const partialQuickSort = ( } }; +const escapeRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Compute how relevant an item's searchable text is for the given search words. + * + * A whole-word match ("car" in "a car") ranks higher than a prefix match + * ("car" in "cartoon"), which itself ranks higher than a match found somewhere + * else inside a word ("car" in "scar"). This lets us prioritize complete word + * matches over the partial matches that the substring-based search engine also + * returns. + * + * The result is always strictly positive so an item that the search engine + * matched is never excluded from the results. + */ +export const getTextSearchRelevance = ( + itemText: string, + searchWords: Array +): number => { + if (searchWords.length === 0) return 1; + + const lowerCasedItemText = itemText.toLowerCase(); + let totalScore = 0; + for (const searchWord of searchWords) { + const escapedWord = escapeRegExp(searchWord); + if (new RegExp(`\\b${escapedWord}\\b`).test(lowerCasedItemText)) { + // Whole-word match ("car" in "a car"): the search term is a complete word. + totalScore += 1; + } else if (new RegExp(`\\b${escapedWord}`).test(lowerCasedItemText)) { + // Prefix match: the search term is only the start of a longer word + // ("car" in "card", "cartoon"). Heavily penalized so these incomplete + // word matches rank well below complete word matches. + totalScore += 0.2; + } else if (lowerCasedItemText.includes(searchWord)) { + // Match somewhere inside a word ("car" in "scar"). Penalized even more. + totalScore += 0.1; + } + } + + // Keep the relevance strictly positive so a matched item is never filtered + // out, while still being lower than any real match. + return Math.max(totalScore / searchWords.length, 0.05); +}; + /** * Filter a list of items according to the chosen category * and the chosen filters. @@ -126,7 +170,8 @@ export const filterSearchItems = ( searchItems: ?Array, chosenCategory: ?ChosenCategory, chosenFilters: ?Set, - searchFilters?: Array> + searchFilters?: Array>, + getSearchItemRelevance?: (searchItem: SearchItem) => number ): ?Array => { if (!searchItems) return null; @@ -178,16 +223,22 @@ export const filterSearchItems = ( }); let sortedSearchItems = filteredSearchItems; - if (searchFilters) { + if (searchFilters || getSearchItemRelevance) { let pertinenceMin = 1; let pertinenceMax = 0; const weightedSearchItems = filteredSearchItems .map(searchItem => { - let pertinence = 1; - for (const searchFilter of searchFilters) { - pertinence *= searchFilter.getPertinence(searchItem); - if (pertinence === 0) { - return null; + // Seed the pertinence with the text search relevance so that whole-word + // matches are ranked above partial matches, then let the filters refine it. + let pertinence = getSearchItemRelevance + ? getSearchItemRelevance(searchItem) + : 1; + if (searchFilters) { + for (const searchFilter of searchFilters) { + pertinence *= searchFilter.getPertinence(searchItem); + if (pertinence === 0) { + return null; + } } } pertinenceMin = Math.min(pertinenceMin, pertinence); @@ -335,12 +386,87 @@ export const useSearchItem = ( } items in ${totalTime.toFixed(3)}ms.` ); + // Prioritize whole-word matches (e.g. "car") over partial matches + // (e.g. "card", "cartoon") returned by the substring search engine. + const searchWords = searchText + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + const getSearchItemRelevance = (searchItem: SearchItem) => { + let textRelevance = getTextSearchRelevance( + getItemDescription(searchItem), + searchWords + ); + + // When an item has many tags but only a few match the search, the + // match is diluted: it is likely less focused on what the user is + // looking for (e.g. an aerosol tagged "car" among 30 other tags). + // Reduce its score sharply, proportionally to the share of + // matching tags, so a heavily diluted match sinks below cleaner + // partial matches, while an item whose tags mostly match keeps a + // near-full score. A small floor keeps the item in the results. + // $FlowFixMe[prop-missing] - only AssetShortHeader/ResourceV2 have tags. + const tags: ?Array = searchItem.tags; + if (tags && tags.length > 0) { + const matchingTagsCount = tags.filter(tag => + getTextSearchRelevance(tag, searchWords) >= 0.5 + ).length; + const matchingTagsRatio = matchingTagsCount / tags.length; + textRelevance *= 0.15 + 0.85 * matchingTagsRatio; + } + + // Boost items whose name itself contains the searched word: a + // match in the name is a much stronger signal of relevance than a + // match found only among the tags. The bonus is proportional to + // how well the name matches (whole-word matches count for more + // than partial ones, see getTextSearchRelevance). + // $FlowFixMe[prop-missing] - most searchable items have a name. + const name: ?string = searchItem.name; + if (name) { + const nameRelevanceBonus = 0.5; + textRelevance += + nameRelevanceBonus * + getTextSearchRelevance(name, searchWords); + } + + // Boost items that have a tag matching the search exactly (e.g. + // searching "car" on an asset tagged "car"). An exact tag match is + // a strong signal, so it is ranked just after a match found in the + // asset's name: the bonus is deliberately kept below the name bonus + // above so a name match still ranks first. + if (tags && tags.length > 0) { + const normalizedSearchText = searchText.trim().toLowerCase(); + const hasExactTagMatch = tags.some( + tag => tag.trim().toLowerCase() === normalizedSearchText + ); + if (hasExactTagMatch) { + const exactTagRelevanceBonus = 0.35; + textRelevance += exactTagRelevanceBonus; + } + } + + // For word searches, prefer 3D assets over 2D ones: they get a + // relevance bonus so that, at comparable text relevance, a 3D + // asset ranks above a 2D one. The bonus is deliberately moderate + // (not a strict "all 3D before all 2D" offset) so that a 2D asset + // with a clearly better score still ranks above a weakly matching + // 3D asset (e.g. one matching only through one of its many tags). + // Non-asset items (e.g. packs) have no objectType, so they are + // unaffected. + const threeDRelevanceBonus = 0.25; + const is3DAsset = + // $FlowFixMe[prop-missing] - only AssetShortHeader has objectType. + searchItem.objectType === 'Scene3D::Model3DObject'; + return is3DAsset ? textRelevance + threeDRelevanceBonus : textRelevance; + }; + setSearchResults( filterSearchItems( partialSearchResults, chosenCategory, chosenFilters, - searchFilters + searchFilters, + getSearchItemRelevance ) ); }); From ce83c94bcbd9a9b818d9517db0748643b2433691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Fri, 24 Jul 2026 21:31:20 +0200 Subject: [PATCH 2/4] Order search results in strict priority bands Rank results with non-overlapping bands so the priority is strict: 3D assets with an exact word match, then 2D assets with an exact word match, then everything else (partial name match or single full tag) ordered by score with a malus when the item has too many tags. Document each score weight as a named, commented constant. --- newIDE/app/src/UI/Search/UseSearchItem.js | 117 +++++++++++++--------- 1 file changed, 71 insertions(+), 46 deletions(-) diff --git a/newIDE/app/src/UI/Search/UseSearchItem.js b/newIDE/app/src/UI/Search/UseSearchItem.js index a1d802d8bd7f..b2e05590fc15 100644 --- a/newIDE/app/src/UI/Search/UseSearchItem.js +++ b/newIDE/app/src/UI/Search/UseSearchItem.js @@ -392,72 +392,96 @@ export const useSearchItem = ( .toLowerCase() .split(/\s+/) .filter(Boolean); + const normalizedSearchText = searchText.trim().toLowerCase(); + + // ---- Score weights ---- + // Width of a priority band. The within-band score always stays well + // below this value, so an item in a higher band always outranks any + // item in a lower band (strict, non-overlapping ordering). + const BAND_WIDTH = 10; + // Weight of the name match added to the within-band score, so that + // (inside a band) an item with the searched word in its name ranks + // above one matching only through its tags. + const NAME_MATCH_WEIGHT = 0.5; + // Many-tags malus: the within-band score is multiplied by + // MANY_TAGS_MALUS_FLOOR + (1 - MANY_TAGS_MALUS_FLOOR) * ratio of + // matching tags. An item whose tags nearly all match keeps almost + // its full score; one matching through a tiny fraction of its tags + // is reduced down to the floor (never to zero, so it stays listed). + const MANY_TAGS_MALUS_FLOOR = 0.15; + // A tag counts as "matching" when its text relevance reaches at + // least this threshold (a prefix match scores 0.2, a whole-word + // match 1, so this keeps only strong tag matches). + const MATCHING_TAG_RELEVANCE_THRESHOLD = 0.5; + const getSearchItemRelevance = (searchItem: SearchItem) => { - let textRelevance = getTextSearchRelevance( + // Base text relevance (whole word > prefix > substring). Only used + // to order items *within* a band, never to cross between bands. + let withinBandScore = getTextSearchRelevance( getItemDescription(searchItem), searchWords ); + // How well the item's name matches. A whole-word match of the + // search term in the name yields a relevance >= 1. + // $FlowFixMe[prop-missing] - most searchable items have a name. + const name: ?string = searchItem.name; + const nameRelevance = name + ? getTextSearchRelevance(name, searchWords) + : 0; + // When an item has many tags but only a few match the search, the // match is diluted: it is likely less focused on what the user is // looking for (e.g. an aerosol tagged "car" among 30 other tags). - // Reduce its score sharply, proportionally to the share of - // matching tags, so a heavily diluted match sinks below cleaner - // partial matches, while an item whose tags mostly match keeps a - // near-full score. A small floor keeps the item in the results. + // Reduce the within-band score sharply, proportionally to the + // share of matching tags, so a heavily diluted match sinks below + // cleaner partial matches. A small floor keeps it in the results. // $FlowFixMe[prop-missing] - only AssetShortHeader/ResourceV2 have tags. const tags: ?Array = searchItem.tags; if (tags && tags.length > 0) { - const matchingTagsCount = tags.filter(tag => - getTextSearchRelevance(tag, searchWords) >= 0.5 + const matchingTagsCount = tags.filter( + tag => + getTextSearchRelevance(tag, searchWords) >= + MATCHING_TAG_RELEVANCE_THRESHOLD ).length; const matchingTagsRatio = matchingTagsCount / tags.length; - textRelevance *= 0.15 + 0.85 * matchingTagsRatio; + withinBandScore *= + MANY_TAGS_MALUS_FLOOR + + (1 - MANY_TAGS_MALUS_FLOOR) * matchingTagsRatio; } - // Boost items whose name itself contains the searched word: a - // match in the name is a much stronger signal of relevance than a - // match found only among the tags. The bonus is proportional to - // how well the name matches (whole-word matches count for more - // than partial ones, see getTextSearchRelevance). - // $FlowFixMe[prop-missing] - most searchable items have a name. - const name: ?string = searchItem.name; - if (name) { - const nameRelevanceBonus = 0.5; - textRelevance += - nameRelevanceBonus * - getTextSearchRelevance(name, searchWords); - } - - // Boost items that have a tag matching the search exactly (e.g. - // searching "car" on an asset tagged "car"). An exact tag match is - // a strong signal, so it is ranked just after a match found in the - // asset's name: the bonus is deliberately kept below the name bonus - // above so a name match still ranks first. - if (tags && tags.length > 0) { - const normalizedSearchText = searchText.trim().toLowerCase(); - const hasExactTagMatch = tags.some( + // Within a band, an item whose name contains the searched word + // still ranks above one matching only through its tags. + withinBandScore += NAME_MATCH_WEIGHT * nameRelevance; + + // An "exact word" match: the whole search term appears as a + // complete word in the name, or the item has a tag exactly equal + // to the search term. This is the strongest relevance signal. + const hasWholeWordInName = nameRelevance >= 1; + const hasExactTag = !!( + tags && + tags.some( tag => tag.trim().toLowerCase() === normalizedSearchText - ); - if (hasExactTagMatch) { - const exactTagRelevanceBonus = 0.35; - textRelevance += exactTagRelevanceBonus; - } - } + ) + ); + const hasExactWord = hasWholeWordInName || hasExactTag; - // For word searches, prefer 3D assets over 2D ones: they get a - // relevance bonus so that, at comparable text relevance, a 3D - // asset ranks above a 2D one. The bonus is deliberately moderate - // (not a strict "all 3D before all 2D" offset) so that a 2D asset - // with a clearly better score still ranks above a weakly matching - // 3D asset (e.g. one matching only through one of its many tags). - // Non-asset items (e.g. packs) have no objectType, so they are - // unaffected. - const threeDRelevanceBonus = 0.25; + // 3D assets are preferred over 2D ones. const is3DAsset = // $FlowFixMe[prop-missing] - only AssetShortHeader has objectType. searchItem.objectType === 'Scene3D::Model3DObject'; - return is3DAsset ? textRelevance + threeDRelevanceBonus : textRelevance; + + // Strict, non-overlapping bands enforce the priority order: + // 1. 3D assets with an exact word match (+2 * BAND_WIDTH) + // 2. 2D assets with an exact word match (+1 * BAND_WIDTH) + // 3. everything else (partial name match / single full tag), + // ordered by the within-band score with the many-tags malus. + let score = withinBandScore; + if (hasExactWord) { + score += BAND_WIDTH; // Lift exact-word matches above the rest. + if (is3DAsset) score += BAND_WIDTH; // 3D-exact above 2D-exact. + } + return score; }; setSearchResults( @@ -489,6 +513,7 @@ export const useSearchItem = ( chosenFilters, searchFilters, searchApi, + getItemDescription, ] ); From ac2ad9c704f8aedcb61741f3be91dfcacfa68845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 27 Jul 2026 13:02:29 +0200 Subject: [PATCH 3/4] Move relevance scoring out of UseSearchItem so callers define search bands --- .../app/src/AssetStore/AssetStoreContext.js | 28 ++- .../app/src/UI/Search/SearchItemRelevance.js | 106 ++++++++++++ newIDE/app/src/UI/Search/UseSearchItem.js | 159 ++---------------- 3 files changed, 147 insertions(+), 146 deletions(-) create mode 100644 newIDE/app/src/UI/Search/SearchItemRelevance.js diff --git a/newIDE/app/src/AssetStore/AssetStoreContext.js b/newIDE/app/src/AssetStore/AssetStoreContext.js index fd829cb31689..90edd09c80f5 100644 --- a/newIDE/app/src/AssetStore/AssetStoreContext.js +++ b/newIDE/app/src/AssetStore/AssetStoreContext.js @@ -18,6 +18,10 @@ import { type PrivateAssetPackListingData, } from '../Utils/GDevelopServices/Shop'; import { useSearchItem, type SearchFilter } from '../UI/Search/UseSearchItem'; +import { + getDefaultSearchItemRelevance, + SEARCH_BAND_WIDTH, +} from '../UI/Search/SearchItemRelevance'; import { TagAssetStoreSearchFilter, AnimatedAssetStoreSearchFilter, @@ -149,6 +153,24 @@ const getAssetShortHeaderSearchTerms = (assetShortHeader: AssetShortHeader) => { ); }; +// Among the assets exactly matching the search, prefer 3D ones over 2D ones +// by lifting them one band higher. +const getAssetShortHeaderRelevance = ( + assetShortHeader: AssetShortHeader, + itemText: string, + searchText: string +): number => { + const relevance = getDefaultSearchItemRelevance( + assetShortHeader, + itemText, + searchText + ); + return assetShortHeader.objectType === 'Scene3D::Model3DObject' && + relevance >= SEARCH_BAND_WIDTH + ? relevance + SEARCH_BAND_WIDTH + : relevance; +}; + const getPublicAssetPackSearchTerms = (assetPack: PublicAssetPack) => assetPack.name + '\n' + assetPack.tag; @@ -477,7 +499,8 @@ export const AssetStoreStateProvider = ({ searchText, chosenCategory, chosenFilters, - assetSearchFilters + assetSearchFilters, + getAssetShortHeaderRelevance ); // $FlowFixMe[incompatible-type] - this filter works for both public and private packs @@ -601,7 +624,8 @@ export const AssetStoreStateProvider = ({ searchText, chosenCategory, chosenFilters, - searchFilters + searchFilters, + getAssetShortHeaderRelevance ), setInitialPackUserFriendlySlug, getAssetShortHeaderFromId, diff --git a/newIDE/app/src/UI/Search/SearchItemRelevance.js b/newIDE/app/src/UI/Search/SearchItemRelevance.js new file mode 100644 index 000000000000..f6edda62b7b1 --- /dev/null +++ b/newIDE/app/src/UI/Search/SearchItemRelevance.js @@ -0,0 +1,106 @@ +// @flow + +/** + * Relevance scoring for searched items, ranking them in strict priority + * "bands": an item in a higher band always outranks any item in a lower band. + */ + +// What the default relevance reads on an item. Extra properties are ignored. +type SearchableItemProperties = { +name?: string, +tags?: Array }; + +export type GetSearchItemRelevance = ( + searchItem: SearchItem, + itemText: string, + searchText: string +) => number; + +// Width of a priority band. Within-band scores stay well below this value, so +// callers can build extra bands by adding it to the default relevance. +export const SEARCH_BAND_WIDTH = 10; +// Weight of the name match, so a name match ranks above a tags-only match. +const NAME_MATCH_WEIGHT = 0.5; +// Floor of the many-tags malus, so a diluted match sinks but stays listed. +const MANY_TAGS_MALUS_FLOOR = 0.15; +// Minimum text relevance for a tag to count as matching. +const MATCHING_TAG_RELEVANCE_THRESHOLD = 0.5; + +const escapeRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getSearchWords = (searchText: string): Array => + searchText + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + +/** + * Text relevance of an item for the search words: whole-word match ("car" in + * "a car") > prefix match ("car" in "cartoon") > substring ("car" in "scar"). + * Always strictly positive so a matched item is never excluded. + */ +const getTextSearchRelevance = ( + itemText: string, + searchWords: Array +): number => { + if (searchWords.length === 0) return 1; + + const lowerCasedItemText = itemText.toLowerCase(); + let totalScore = 0; + for (const searchWord of searchWords) { + const escapedWord = escapeRegExp(searchWord); + if (new RegExp(`\\b${escapedWord}\\b`).test(lowerCasedItemText)) { + totalScore += 1; // Whole-word match. + } else if (new RegExp(`\\b${escapedWord}`).test(lowerCasedItemText)) { + totalScore += 0.2; // Prefix match. + } else if (lowerCasedItemText.includes(searchWord)) { + totalScore += 0.1; // Match inside a word. + } + } + + return Math.max(totalScore / searchWords.length, 0.05); +}; + +/** + * Default relevance, ranking items in two bands: "exact matches" (the whole + * search term is a complete word of the item name, or is exactly one of its + * tags) above everything else. Within a band, items are ordered by text + * relevance, refined by the name and diluted when few of many tags match. + * + * An item in the exact match band has a relevance >= SEARCH_BAND_WIDTH. + */ +export const getDefaultSearchItemRelevance: GetSearchItemRelevance = ( + searchItem, + itemText, + searchText +) => { + const searchWords = getSearchWords(searchText); + let withinBandScore = getTextSearchRelevance(itemText, searchWords); + + const { name, tags } = searchItem; + const nameRelevance = name ? getTextSearchRelevance(name, searchWords) : 0; + + // Dilute the match of an item matching through few of its many tags (e.g. + // an aerosol tagged "car" among 30 other tags). + if (tags && tags.length > 0) { + const matchingTagsCount = tags.filter( + tag => + getTextSearchRelevance(tag, searchWords) >= + MATCHING_TAG_RELEVANCE_THRESHOLD + ).length; + withinBandScore *= + MANY_TAGS_MALUS_FLOOR + + (1 - MANY_TAGS_MALUS_FLOOR) * (matchingTagsCount / tags.length); + } + + withinBandScore += NAME_MATCH_WEIGHT * nameRelevance; + + const normalizedSearchText = searchText.trim().toLowerCase(); + const hasExactMatch = + nameRelevance >= 1 || + !!( + tags && + tags.some(tag => tag.trim().toLowerCase() === normalizedSearchText) + ); + + return withinBandScore + (hasExactMatch ? SEARCH_BAND_WIDTH : 0); +}; diff --git a/newIDE/app/src/UI/Search/UseSearchItem.js b/newIDE/app/src/UI/Search/UseSearchItem.js index b2e05590fc15..24f192f50e92 100644 --- a/newIDE/app/src/UI/Search/UseSearchItem.js +++ b/newIDE/app/src/UI/Search/UseSearchItem.js @@ -14,6 +14,10 @@ import { type PrivateGameTemplateListingData, type BundleListingData, } from '../../Utils/GDevelopServices/Shop'; +import { + getDefaultSearchItemRelevance, + type GetSearchItemRelevance, +} from './SearchItemRelevance'; type SearchableItem = | AssetShortHeader @@ -118,50 +122,6 @@ export const partialQuickSort = ( } }; -const escapeRegExp = (text: string): string => - text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -/** - * Compute how relevant an item's searchable text is for the given search words. - * - * A whole-word match ("car" in "a car") ranks higher than a prefix match - * ("car" in "cartoon"), which itself ranks higher than a match found somewhere - * else inside a word ("car" in "scar"). This lets us prioritize complete word - * matches over the partial matches that the substring-based search engine also - * returns. - * - * The result is always strictly positive so an item that the search engine - * matched is never excluded from the results. - */ -export const getTextSearchRelevance = ( - itemText: string, - searchWords: Array -): number => { - if (searchWords.length === 0) return 1; - - const lowerCasedItemText = itemText.toLowerCase(); - let totalScore = 0; - for (const searchWord of searchWords) { - const escapedWord = escapeRegExp(searchWord); - if (new RegExp(`\\b${escapedWord}\\b`).test(lowerCasedItemText)) { - // Whole-word match ("car" in "a car"): the search term is a complete word. - totalScore += 1; - } else if (new RegExp(`\\b${escapedWord}`).test(lowerCasedItemText)) { - // Prefix match: the search term is only the start of a longer word - // ("car" in "card", "cartoon"). Heavily penalized so these incomplete - // word matches rank well below complete word matches. - totalScore += 0.2; - } else if (lowerCasedItemText.includes(searchWord)) { - // Match somewhere inside a word ("car" in "scar"). Penalized even more. - totalScore += 0.1; - } - } - - // Keep the relevance strictly positive so a matched item is never filtered - // out, while still being lower than any real match. - return Math.max(totalScore / searchWords.length, 0.05); -}; - /** * Filter a list of items according to the chosen category * and the chosen filters. @@ -280,7 +240,10 @@ export const useSearchItem = ( searchText: string, chosenCategory: ?ChosenCategory, chosenFilters: ?Set, - searchFilters?: Array> + searchFilters?: Array>, + // Relevance of an item for the search: defined by the caller (which knows + // what is being searched), or this generic text-based default. + getSearchItemRelevance: GetSearchItemRelevance = getDefaultSearchItemRelevance ): ?Array => { const searchApiRef = React.useRef(null); const [searchResults, setSearchResults] = React.useState>( @@ -386,111 +349,18 @@ export const useSearchItem = ( } items in ${totalTime.toFixed(3)}ms.` ); - // Prioritize whole-word matches (e.g. "car") over partial matches - // (e.g. "card", "cartoon") returned by the substring search engine. - const searchWords = searchText - .toLowerCase() - .split(/\s+/) - .filter(Boolean); - const normalizedSearchText = searchText.trim().toLowerCase(); - - // ---- Score weights ---- - // Width of a priority band. The within-band score always stays well - // below this value, so an item in a higher band always outranks any - // item in a lower band (strict, non-overlapping ordering). - const BAND_WIDTH = 10; - // Weight of the name match added to the within-band score, so that - // (inside a band) an item with the searched word in its name ranks - // above one matching only through its tags. - const NAME_MATCH_WEIGHT = 0.5; - // Many-tags malus: the within-band score is multiplied by - // MANY_TAGS_MALUS_FLOOR + (1 - MANY_TAGS_MALUS_FLOOR) * ratio of - // matching tags. An item whose tags nearly all match keeps almost - // its full score; one matching through a tiny fraction of its tags - // is reduced down to the floor (never to zero, so it stays listed). - const MANY_TAGS_MALUS_FLOOR = 0.15; - // A tag counts as "matching" when its text relevance reaches at - // least this threshold (a prefix match scores 0.2, a whole-word - // match 1, so this keeps only strong tag matches). - const MATCHING_TAG_RELEVANCE_THRESHOLD = 0.5; - - const getSearchItemRelevance = (searchItem: SearchItem) => { - // Base text relevance (whole word > prefix > substring). Only used - // to order items *within* a band, never to cross between bands. - let withinBandScore = getTextSearchRelevance( - getItemDescription(searchItem), - searchWords - ); - - // How well the item's name matches. A whole-word match of the - // search term in the name yields a relevance >= 1. - // $FlowFixMe[prop-missing] - most searchable items have a name. - const name: ?string = searchItem.name; - const nameRelevance = name - ? getTextSearchRelevance(name, searchWords) - : 0; - - // When an item has many tags but only a few match the search, the - // match is diluted: it is likely less focused on what the user is - // looking for (e.g. an aerosol tagged "car" among 30 other tags). - // Reduce the within-band score sharply, proportionally to the - // share of matching tags, so a heavily diluted match sinks below - // cleaner partial matches. A small floor keeps it in the results. - // $FlowFixMe[prop-missing] - only AssetShortHeader/ResourceV2 have tags. - const tags: ?Array = searchItem.tags; - if (tags && tags.length > 0) { - const matchingTagsCount = tags.filter( - tag => - getTextSearchRelevance(tag, searchWords) >= - MATCHING_TAG_RELEVANCE_THRESHOLD - ).length; - const matchingTagsRatio = matchingTagsCount / tags.length; - withinBandScore *= - MANY_TAGS_MALUS_FLOOR + - (1 - MANY_TAGS_MALUS_FLOOR) * matchingTagsRatio; - } - - // Within a band, an item whose name contains the searched word - // still ranks above one matching only through its tags. - withinBandScore += NAME_MATCH_WEIGHT * nameRelevance; - - // An "exact word" match: the whole search term appears as a - // complete word in the name, or the item has a tag exactly equal - // to the search term. This is the strongest relevance signal. - const hasWholeWordInName = nameRelevance >= 1; - const hasExactTag = !!( - tags && - tags.some( - tag => tag.trim().toLowerCase() === normalizedSearchText - ) - ); - const hasExactWord = hasWholeWordInName || hasExactTag; - - // 3D assets are preferred over 2D ones. - const is3DAsset = - // $FlowFixMe[prop-missing] - only AssetShortHeader has objectType. - searchItem.objectType === 'Scene3D::Model3DObject'; - - // Strict, non-overlapping bands enforce the priority order: - // 1. 3D assets with an exact word match (+2 * BAND_WIDTH) - // 2. 2D assets with an exact word match (+1 * BAND_WIDTH) - // 3. everything else (partial name match / single full tag), - // ordered by the within-band score with the many-tags malus. - let score = withinBandScore; - if (hasExactWord) { - score += BAND_WIDTH; // Lift exact-word matches above the rest. - if (is3DAsset) score += BAND_WIDTH; // 3D-exact above 2D-exact. - } - return score; - }; - setSearchResults( filterSearchItems( partialSearchResults, chosenCategory, chosenFilters, searchFilters, - getSearchItemRelevance + searchItem => + getSearchItemRelevance( + searchItem, + getItemDescription(searchItem), + searchText + ) ) ); }); @@ -514,6 +384,7 @@ export const useSearchItem = ( searchFilters, searchApi, getItemDescription, + getSearchItemRelevance, ] ); From aebc861ec392c7d0978b120085957f73285ef83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 27 Jul 2026 18:58:04 +0200 Subject: [PATCH 4/4] Fix flow --- newIDE/app/src/UI/Search/SearchItemRelevance.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/newIDE/app/src/UI/Search/SearchItemRelevance.js b/newIDE/app/src/UI/Search/SearchItemRelevance.js index f6edda62b7b1..a12afc480694 100644 --- a/newIDE/app/src/UI/Search/SearchItemRelevance.js +++ b/newIDE/app/src/UI/Search/SearchItemRelevance.js @@ -6,9 +6,16 @@ */ // What the default relevance reads on an item. Extra properties are ignored. -type SearchableItemProperties = { +name?: string, +tags?: Array }; +// An interface, so class instances (like the resources store items) are also +// accepted. +interface SearchableItemProperties { + +name?: string; + +tags?: Array; +} -export type GetSearchItemRelevance = ( +// Contravariant in SearchItem: a scorer reading only the generic searchable +// properties can be used wherever a scorer for a more specific item is needed. +export type GetSearchItemRelevance<-SearchItem> = ( searchItem: SearchItem, itemText: string, searchText: string