Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
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> = {};
|
||||
|
||||
// 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);
|
||||
|
||||
const score = weightedSum(parts, weights);
|
||||
|
||||
return {
|
||||
recipeId: candidate.recipeId,
|
||||
titleSv: candidate.titleSv,
|
||||
score: Math.round(score * 10) / 10,
|
||||
parts,
|
||||
whySv: buildWhySv(candidate, ctx, parts),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user