Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
@@ -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 (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> = {};
// 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);
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));
}