34 lines
1.8 KiB
JavaScript
34 lines
1.8 KiB
JavaScript
import { normalizeSearchText } from '#tofu/tools/text/normalizeSearchText.js'
|
|
import { scoreTextSimilarity } from '#tofu/tools/text/scoreTextSimilarity.js'
|
|
|
|
// Deliberately basic autocompletion: Dice coefficient on bigrams plus a prefix
|
|
// bonus, scored against the whole recipe name and each of its words. It is meant
|
|
// to be replaced by a better ranking without touching any caller.
|
|
const MINIMUM_SCORE = 0.2
|
|
// Above this, the query is literally part of the name rather than merely close
|
|
// to it. When such a match exists the approximate ones are noise: typing
|
|
// « gratin » must not also offer « ratatouille » because they share two bigrams.
|
|
const STRONG_SCORE = 0.75
|
|
|
|
export function rankRecipeSuggestions(query, recipes, limit = 5) {
|
|
const normalizedQuery = normalizeSearchText(query)
|
|
if (normalizedQuery.length === 0 || !Array.isArray(recipes)) return []
|
|
|
|
const matches = []
|
|
for (const recipe of recipes) {
|
|
const name = typeof recipe?.name === 'string' ? recipe.name : ''
|
|
// A single word of the name must be able to match on its own, otherwise a
|
|
// long name dilutes the bigrams of a short query ("knoci" in "Gnocchis a la sauge").
|
|
const candidates = [name, ...normalizeSearchText(name).split(' ')]
|
|
const score = Math.max(...candidates.map((text) => scoreTextSimilarity(normalizedQuery, text)))
|
|
if (score >= MINIMUM_SCORE) matches.push({ recipe, name, score })
|
|
}
|
|
|
|
matches.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name, 'fr'))
|
|
// Approximate matches stay only as long as no literal one was found: a typo
|
|
// deserves alternatives, a spelled-out name does not.
|
|
const kept = matches[0]?.score >= STRONG_SCORE
|
|
? matches.filter((match) => match.score >= STRONG_SCORE)
|
|
: matches
|
|
return kept.slice(0, limit).map((match) => match.recipe)
|
|
}
|