392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
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<string, number> = {};
|
||
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<string>(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<string, unknown>;
|
||
|
||
// 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<string, number>, weights: ScoringWeights): number {
|
||
let total = 0;
|
||
for (const [key, value] of Object.entries(parts)) {
|
||
const weight = (weights as unknown as Record<string, number>)[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));
|
||
}
|