Tofu/app/tools/text/rankRecipeSuggestions.js

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)
}