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).
|
* 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.
|
* 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) {
|
export async function recommendationRoutes(app: FastifyInstance) {
|
||||||
const auth = { preHandler: [app.authenticate] };
|
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) ---
|
// --- 3. Kandidater: publicerade recept för måltidstypen (eller taggen) ---
|
||||||
// Baka-fliken skickar tag=baking i stället för en måltidstyp.
|
// 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
|
const candidates = await app.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.recipes)
|
.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 + "%"})`,
|
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.
|
// Kock/källa: "ramsay"/"ottolenghi" → recept krediterade den kocken.
|
||||||
ilike(schema.recipes.creatorDisplayName, `%${q.search}%`),
|
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
|
: q.holiday
|
||||||
? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})`
|
? 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 {
|
return {
|
||||||
mealType: q.mealType,
|
mealType: q.mealType,
|
||||||
context: {
|
context: {
|
||||||
@@ -541,6 +615,7 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
remainingProteinG: ctx.remainingProteinG,
|
remainingProteinG: ctx.remainingProteinG,
|
||||||
craving: craving ?? null,
|
craving: craving ?? null,
|
||||||
view: q.view,
|
view: q.view,
|
||||||
|
searchSuggestion,
|
||||||
},
|
},
|
||||||
mealBoxSuggestions,
|
mealBoxSuggestions,
|
||||||
recommendations: recommendations.map((r) => ({
|
recommendations: recommendations.map((r) => ({
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ interface WhatToEatResponse {
|
|||||||
activeHolidays: Array<{ slug: string; name: string }>;
|
activeHolidays: Array<{ slug: string; name: string }>;
|
||||||
remainingKcal: number;
|
remainingKcal: number;
|
||||||
remainingProteinG: 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) => (
|
{shownRecs.map((rec, index) => (
|
||||||
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
|
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
|
||||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user