import type { MemoryItem, TasteSignal } from "@app/shared-types"; import type { CookingAssumption, ProvenanceEntry, RecommendationCandidate, RecommendationContext, ScoredRecommendation, ScoringWeights, } from "./types.js"; import { DEFAULT_WEIGHTS } from "./types.js"; import { buildWhySv } from "./explain.js"; /** * Poängsätt en kandidat (0–100-skala per delkomponent, viktad summa). * Helt deterministisk: samma indata → samma poäng → förklarbar rekommendation. */ export function scoreCandidate( candidate: RecommendationCandidate, ctx: RecommendationContext, weights: ScoringWeights = DEFAULT_WEIGHTS, ): ScoredRecommendation { const parts: Record = {}; const provenance: ProvenanceEntry[] = []; // 1. Ingredienstäckning – kärnan i "utgå från vad som finns hemma". parts.coverage = candidate.coverage.coverage; // 2. Utgångsdatum: störst poäng när receptet räddar varor som snart går ut. const expiring = candidate.coverage.expiringUsed; if (expiring.length === 0) { parts.expiry = 0; } else { const mostUrgent = Math.min( ...expiring.map((m) => (m.mostUrgentDaysLeft == null ? 99 : m.mostUrgentDaysLeft)), ); parts.expiry = mostUrgent <= 1 ? 1 : mostUrgent <= 3 ? 0.8 : 0.5; } // 3. Näringsfit mot återstående dagsmål. parts.nutritionFit = nutritionFit(candidate, ctx); // 4. Smak: favoritkök + styrka nära preferens. parts.taste = ctx.favoriteCuisines.includes(candidate.cuisine) ? 1 : 0.4; // 5. Betyg: hushållets egna betyg väger tyngst, annars community (kräver volym). if (candidate.householdRating != null) { parts.rating = clamp01((candidate.householdRating - 2.5) / 2.5); } else if (candidate.ratingAverage != null && candidate.ratingCount >= 5) { parts.rating = clamp01((candidate.ratingAverage - 3) / 2); } else { parts.rating = 0.5; } // 6–7. Säsong & högtid. parts.season = candidate.peakSeasons.includes(ctx.currentSeason) ? 1 : 0.3; parts.holiday = ctx.activeHolidayTags.length > 0 && candidate.holidayTags.some((t) => ctx.activeHolidayTags.includes(t)) ? 1 : 0; // 8. Tid: hård maxgräns om satt, annars vardagsbonus för snabbt. if (ctx.maxMinutes != null && candidate.totalTimeMinutes > ctx.maxMinutes) { parts.time = -1; // diskvalificerande straff hanteras i rankAll } else if (ctx.isWeekday) { parts.time = candidate.totalTimeMinutes <= 25 ? 1 : candidate.totalTimeMinutes <= 45 ? 0.6 : 0.2; } else { parts.time = candidate.totalTimeMinutes <= 90 ? 0.7 : 0.5; } // 9. Budget. const cost = candidate.estimatedCostMinorPerPortion; if (ctx.maxCostMinorPerPortion != null && cost != null) { parts.budget = cost <= ctx.maxCostMinorPerPortion ? 1 : -0.5; } else if (cost != null) { parts.budget = cost <= 25 ? 1 : cost <= 45 ? 0.6 : 0.3; } else { parts.budget = 0.4; } // 10. Variation: nyligen lagat straffas (spec §25: variation). const days = candidate.daysSinceLastCooked; parts.variety = days == null ? 0.8 : days < 7 ? 0 : days < 14 ? 0.4 : 1; // 11. Väder (spec §29): varmt → grill/sallad, kallt → gryta/soppa. parts.weather = weatherFit(candidate, ctx); // 12. "Jag är sugen på" (spec §19). parts.craving = cravingFit(candidate, ctx); // 13–15. S1 personalisering — endast om samtycke granted. if (ctx.personalizationEnabled) { const memoryResult = memoryFit(candidate, ctx); parts.memoryFit = memoryResult.score; provenance.push(...memoryResult.provenance); const tasteResult = tasteFit(candidate, ctx); parts.tasteFit = tasteResult.score; provenance.push(...tasteResult.provenance); const assumptionResult = cookingAssumptionFit(candidate, ctx); parts.cookingAssumptionFit = assumptionResult.score; provenance.push(...assumptionResult.provenance); } else { parts.memoryFit = 0; parts.tasteFit = 0; parts.cookingAssumptionFit = 0; } // 16. Måltids-lämplighet: nedvikta lätta övergångsrätter (frukost/dessert) vid huvudmåltid. const mainMeal = ctx.mealType === "dinner" || ctx.mealType === "lunch"; const crossover = (candidate.mealTypes ?? []).includes("breakfast") || (candidate.mealTypes ?? []).includes("dessert"); parts.mealFit = mainMeal && crossover ? 0.15 : 1; const score = weightedSum(parts, weights); return { recipeId: candidate.recipeId, titleSv: candidate.titleSv, score: Math.round(score * 10) / 10, parts, whySv: buildWhySv(candidate, ctx, parts, provenance), missingIngredients: candidate.coverage.missing .filter((m) => !m.optional) .map((m) => m.displayNameSv), usesExpiring: expiring.map((m) => ({ nameSv: m.displayNameSv, daysLeft: m.mostUrgentDaysLeft, })), coveragePercent: Math.round(candidate.coverage.coverage * 100), provenance, }; } /** Ranka alla kandidater; kandidater över tidsgränsen filtreras bort. */ export function rankAll( candidates: RecommendationCandidate[], ctx: RecommendationContext, weights: ScoringWeights = DEFAULT_WEIGHTS, limit = 5, ): ScoredRecommendation[] { return candidates .map((c) => scoreCandidate(c, ctx, weights)) .filter((s) => (s.parts.time ?? 0) >= 0) .sort((a, b) => b.score - a.score) .slice(0, limit); } function nutritionFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number { let fit = 0.5; const protein = candidate.nutritionPerPortion.proteinG; if (ctx.remainingProteinG != null && ctx.remainingProteinG > 0) { // Ju närmare receptet fyller proteinluckan, desto bättre (upp till 1). fit = clamp01(protein / Math.max(20, ctx.remainingProteinG * 0.6)); } if (ctx.isTrainingDay && protein >= 35) fit = Math.min(1, fit + 0.3); if (ctx.remainingKcal != null && ctx.remainingKcal > 0) { const kcal = candidate.nutritionPerPortion.kcal; if (kcal > ctx.remainingKcal * 1.3) fit *= 0.5; } return clamp01(fit); } function weatherFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number { if (!ctx.weather || ctx.weather === "unknown") return 0.5; const title = candidate.titleSv.toLowerCase(); const has = (words: string[]) => words.some((w) => title.includes(w)) || candidate.tags.some((t) => words.includes(t)) || words.includes(candidate.cuisine); switch (ctx.weather) { case "hot": case "warm": return has(["grill", "sallad", "kall", "bowl", "wrap"]) ? 1 : 0.4; case "cold": case "snow": return has(["soppa", "gryta", "långkok", "ugns", "pytt"]) ? 1 : 0.4; case "rain": return has(["gryta", "långkok", "soppa", "paj"]) ? 0.9 : 0.5; default: return 0.5; } } function cravingFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number { const tags = ctx.cravingTags ?? []; if (tags.length === 0 && !ctx.cravingCuisine && ctx.cravingMaxKcal == null) return 0.5; let hits = 0; let checks = 0; if (ctx.cravingCuisine) { checks += 1; if (candidate.cuisine === ctx.cravingCuisine) hits += 1; } if (ctx.cravingMaxKcal != null) { checks += 1; if (candidate.nutritionPerPortion.kcal <= ctx.cravingMaxKcal) hits += 1; } if (tags.length > 0) { checks += 1; const title = candidate.titleSv.toLowerCase(); const candidateTags = new Set(candidate.tags); const matched = tags.some( (t) => candidateTags.has(t) || title.includes(t) || (t === "spicy" && candidate.spiceLevel >= 3) || (t === "quick" && candidate.totalTimeMinutes <= 25) || (t === "high_protein" && candidate.nutritionPerPortion.proteinG >= 35), ); if (matched) hits += 1; } return checks === 0 ? 0.5 : hits / checks; } interface FitResult { score: number; provenance: ProvenanceEntry[]; } function memoryFit(candidate: RecommendationCandidate, ctx: RecommendationContext): FitResult { const memories = ctx.memoryItems ?? []; if (memories.length === 0 || !ctx.personalizationEnabled) { return { score: 0, provenance: [] }; } let score = 0; const provenance: ProvenanceEntry[] = []; for (const memory of memories) { if (memory.paused) continue; const value = (memory.value ?? {}) as Record; // Favoritkök if (memory.kind === "structured_fact" && value.favoriteCuisine === candidate.cuisine) { const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : memory.origin === "observed" ? 0.7 : 0.4; score = Math.max(score, weight); if (weight >= 0.7) { provenance.push({ key: "favoriteCuisine", args: { cuisine: String(value.favoriteCuisine) }, }); } } // Gillade rätter / receptminne if (memory.kind === "recipe_memory" && value.recipeId === candidate.recipeId) { const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : 0.6; score = Math.max(score, weight); } // Gillade ingredienser if ( memory.kind === "structured_fact" && typeof value.likedIngredientId === "string" && candidate.ingredientIds?.includes(value.likedIngredientId) ) { const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 0.9 : 0.5; score = Math.max(score, weight); } } // Matlagningsfrekvens (observed events, ej AI-gissning) if (candidate.daysSinceLastCooked != null && candidate.daysSinceLastCooked <= 30) { // Ingen boost för nyligen lagat (variety straffar redan), men vi noterar mönster. } return { score: clamp01(score), provenance }; } function tasteFit(candidate: RecommendationCandidate, ctx: RecommendationContext): FitResult { const signals = ctx.tasteSignals ?? []; if (signals.length === 0 || !ctx.personalizationEnabled) { return { score: 0, provenance: [] }; } let total = 0; let count = 0; const provenance: ProvenanceEntry[] = []; // Mappa recept till axlar via tags/cuisine/ingredienser (förenklad heuristik). const recipeAxes = detectRecipeAxes(candidate); for (const signal of signals) { if (!recipeAxes.includes(signal.axis)) continue; const contribution = signal.direction * signal.strength; total += contribution; count += 1; if (Math.abs(contribution) >= 0.5) { provenance.push({ key: "tastePreference", args: { axis: signal.axis }, }); } } if (count === 0) return { score: 0, provenance: [] }; const raw = total / count; // -1 … +1 const score = clamp01((raw + 1) / 2); // 0 … 1 return { score, provenance }; } function detectRecipeAxes(candidate: RecommendationCandidate): string[] { const axes: string[] = []; const title = candidate.titleSv.toLowerCase(); const tags = new Set(candidate.tags.map((t) => t.toLowerCase())); if (candidate.spiceLevel >= 3 || tags.has("spicy")) axes.push("spice"); if (tags.has("sött") || tags.has("dessert") || title.includes("socker")) axes.push("sweetness"); if (tags.has("syrligt") || title.includes("citron") || title.includes("lime")) axes.push("acid"); if ( title.includes("krämig") || title.includes("grädd") || tags.has("creamy") || tags.has("krämig") ) axes.push("creaminess"); if (title.includes("vitlök") || tags.has("garlic")) axes.push("garlic"); if (tags.has("herby") || title.includes("dill") || title.includes("basilika")) axes.push("herbs"); if (tags.has("umami") || title.includes("soja") || title.includes("svamp")) axes.push("umami"); return axes; } function cookingAssumptionFit( candidate: RecommendationCandidate, ctx: RecommendationContext, ): FitResult { const assumptions = ctx.cookingAssumptions ?? []; if (assumptions.length === 0 || !ctx.personalizationEnabled) { return { score: 0, provenance: [] }; } const ids = candidate.ingredientIds ?? []; let total = 0; let matched = 0; let bestIngredient: string | null = null; let bestScore = -1; for (const assumption of assumptions) { if (!ids.includes(assumption.canonicalIngredientId)) continue; const eaten = assumption.averageEatenPortions ?? 0; const leftovers = assumption.averageLeftoverPortions ?? 0; const observed = assumption.observationCount; if (observed < 2) continue; const finishRate = eaten > 0 ? eaten / (eaten + leftovers) : 0; const score = clamp01(finishRate * Math.min(1, observed / 5)); total += score; matched += 1; if (score > bestScore) { bestScore = score; bestIngredient = assumption.canonicalIngredientId; } } if (matched === 0) return { score: 0, provenance: [] }; const provenance: ProvenanceEntry[] = []; if (bestIngredient && bestScore >= 0.7) { provenance.push({ key: "usesStapleYouFinish", args: { ingredient: bestIngredient } }); } return { score: clamp01(total / matched), provenance }; } function weightedSum(parts: Record, weights: ScoringWeights): number { let total = 0; for (const [key, value] of Object.entries(parts)) { const weight = (weights as unknown as Record)[key] ?? 0; total += clampPart(value) * weight; } return total; } function clamp01(v: number): number { return Math.max(0, Math.min(1, v)); } function clampPart(v: number): number { return Math.max(-1, Math.min(1, v)); }