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:
Claude
2026-08-19 20:55:03 +00:00
parent 2a7f366f6a
commit e3cced8b62
2 changed files with 83 additions and 0 deletions
+75
View File
@@ -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) => ({
+8
View File
@@ -51,6 +51,7 @@ interface WhatToEatResponse {
activeHolidays: Array<{ slug: string; name: string }>;
remainingKcal: number;
remainingProteinG: number;
searchSuggestion?: string | null;
};
}
@@ -244,6 +245,13 @@ export default function WhatToEatScreen() {
/>
)}
{/* "Menade du …?" stavningsförslag när sök gav noll träffar. */}
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
<Pressable onPress={() => submitSearch(query.data!.context.searchSuggestion!)}>
<Tag label={`Menade du ${query.data.context.searchSuggestion}?`} tone="accent" />
</Pressable>
)}
{shownRecs.map((rec, index) => (
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>