Files
Cibello-app/packages/recommendation-engine/src/scoring.ts
T
Sven (AAMOS AI) 0885f5bceb feat(recommendation-engine): S1 personalisering för 'Vad ska vi äta?'
- Mallbaserad proveniens i 12 språk (inga fria AI-texter i rekommendationer).
- memoryFit, tasteFit, cookingAssumptionFit endast vid personalization-samtycke.
- Hård grind i API:et: läser memory_items/taste_signals/cooking_assumption_profiles
  endast när userConsents.personalization = granted.
- NON_PERSONALIZED_WEIGHTS bevarar existerande beteende vid avsaknad av samtycke.
- Positiv, icke-restriktiv näringscopy (R7).
- Deterministisk scoring + enhetstester för S1.
- Integrationstest som verifierar provenans-gate med/utan samtycke.
2026-08-10 03:23:31 +07:00

381 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;
}
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));
}