Files
Cibello-app/packages/recommendation-engine/src/scoring.ts
T

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (0100-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;
}
// 67. 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);
// 1315. 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));
}