feat(sok): fuzzy kock-sok + 'Menade du...?'-forslag
Felstavade kocknamn hittar ratt kock: aliaslista (remsey/ramsey -> Gordon Ramsay) + trigram word_similarity (ramzay/otolengi). Ger nolltraffar ett 'Menade du <term>?'-forslag (tappbart) mot titlar, kocknamn, ingredienser.
This commit is contained in:
@@ -37,6 +37,66 @@ import { loadLocalePreferences } from "../lib/localeContext.js";
|
||||
* 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24).
|
||||
* 5. (Bakom flagga) AAMOS får omranka topplistan – aldrig lägga till recept.
|
||||
*/
|
||||
/** Kock-alias (spec: named-chef search) inkl. medvetna felstavningar, så att
|
||||
* sök hittar rätt kock även vid felskrivning ("remsey" → Gordon Ramsay). */
|
||||
const CHEF_ALIASES: Record<string, string> = {
|
||||
gordon: "Gordon Ramsay",
|
||||
ramsay: "Gordon Ramsay",
|
||||
ramsey: "Gordon Ramsay",
|
||||
remsey: "Gordon Ramsay",
|
||||
remsay: "Gordon Ramsay",
|
||||
jamie: "Jamie Oliver",
|
||||
oliver: "Jamie Oliver",
|
||||
nigella: "Nigella Lawson",
|
||||
lawson: "Nigella Lawson",
|
||||
yotam: "Yotam Ottolenghi",
|
||||
ottolenghi: "Yotam Ottolenghi",
|
||||
marcus: "Marcus Samuelsson",
|
||||
samuelsson: "Marcus Samuelsson",
|
||||
giada: "Giada De Laurentiis",
|
||||
laurentiis: "Giada De Laurentiis",
|
||||
emeril: "Emeril Lagasse",
|
||||
lagasse: "Emeril Lagasse",
|
||||
ina: "Ina Garten",
|
||||
garten: "Ina Garten",
|
||||
"barefoot contessa": "Ina Garten",
|
||||
raymond: "Raymond Blanc",
|
||||
blanc: "Raymond Blanc",
|
||||
rick: "Rick Stein",
|
||||
stein: "Rick Stein",
|
||||
};
|
||||
|
||||
function resolveChefAliases(search: string): string[] {
|
||||
const s = search.toLowerCase().trim();
|
||||
if (s.length < 3) return [];
|
||||
const hits = new Set<string>();
|
||||
for (const [alias, name] of Object.entries(CHEF_ALIASES)) {
|
||||
if (s === alias || s.includes(alias) || alias.includes(s)) hits.add(name);
|
||||
}
|
||||
return [...hits];
|
||||
}
|
||||
|
||||
/** "Menade du …?" – närmaste term via trigram word_similarity mot titlar,
|
||||
* kocknamn, ingredienser och högtider. Null om inget är nära nog. */
|
||||
async function computeSearchSuggestion(
|
||||
app: FastifyInstance,
|
||||
search: string,
|
||||
): Promise<string | null> {
|
||||
const result = await app.db.execute(sql`
|
||||
SELECT term FROM (
|
||||
SELECT title_sv AS term FROM recipes WHERE status = 'published'
|
||||
UNION SELECT DISTINCT creator_display_name FROM recipes WHERE creator_display_name NOT ILIKE '%redaktion%'
|
||||
UNION SELECT name_sv FROM canonical_ingredients
|
||||
UNION SELECT name_sv FROM season_events
|
||||
) c
|
||||
WHERE word_similarity(${search}, term) > 0.4
|
||||
ORDER BY word_similarity(${search}, term) DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const rows = (Array.isArray(result) ? result : result.rows) as Array<{ term: string }>;
|
||||
return rows[0]?.term ?? null;
|
||||
}
|
||||
|
||||
export async function recommendationRoutes(app: FastifyInstance) {
|
||||
const auth = { preHandler: [app.authenticate] };
|
||||
|
||||
@@ -118,6 +178,8 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
|
||||
// --- 3. Kandidater: publicerade recept för måltidstypen (eller taggen) ---
|
||||
// Baka-fliken skickar tag=baking i stället för en måltidstyp.
|
||||
// Felstavade kocknamn (t.ex. "remsey") mappas via alias till rätt kock.
|
||||
const chefMatches = q.search ? resolveChefAliases(q.search) : [];
|
||||
const candidates = await app.db
|
||||
.select()
|
||||
.from(schema.recipes)
|
||||
@@ -139,6 +201,12 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
sql`EXISTS (SELECT 1 FROM recipe_ingredients ri JOIN canonical_ingredients ci ON ci.id = ri.canonical_ingredient_id WHERE ri.recipe_id = ${schema.recipes.id} AND ci.name_sv ILIKE ${"%" + q.search + "%"})`,
|
||||
// Kock/källa: "ramsay"/"ottolenghi" → recept krediterade den kocken.
|
||||
ilike(schema.recipes.creatorDisplayName, `%${q.search}%`),
|
||||
// Felstavat kocknamn via alias ("remsey" → Gordon Ramsay).
|
||||
chefMatches.length
|
||||
? inArray(schema.recipes.creatorDisplayName, chefMatches)
|
||||
: sql`false`,
|
||||
// Fuzzy kocknamn (fångar t.ex. "ramzay"/"otolengi" utan aliaslista).
|
||||
sql`word_similarity(${q.search}, ${schema.recipes.creatorDisplayName}) > 0.42`,
|
||||
)
|
||||
: q.holiday
|
||||
? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})`
|
||||
@@ -528,6 +596,12 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
]),
|
||||
);
|
||||
|
||||
// "Menade du …?" – bara när en sökning gav noll träffar (grov felstavning).
|
||||
const searchSuggestion =
|
||||
q.search && recommendations.length === 0
|
||||
? await computeSearchSuggestion(app, q.search)
|
||||
: null;
|
||||
|
||||
return {
|
||||
mealType: q.mealType,
|
||||
context: {
|
||||
@@ -541,6 +615,7 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
remainingProteinG: ctx.remainingProteinG,
|
||||
craving: craving ?? null,
|
||||
view: q.view,
|
||||
searchSuggestion,
|
||||
},
|
||||
mealBoxSuggestions,
|
||||
recommendations: recommendations.map((r) => ({
|
||||
|
||||
Reference in New Issue
Block a user