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,70 @@
import type { Cuisine } from "@app/shared-types";
export interface ParsedCraving {
tags: string[];
cuisine?: Cuisine | undefined;
maxKcal?: number | undefined;
}
/**
* Deterministisk grundtolkning av "Jag är sugen på …" (spec §19).
* Täcker vanliga svenska uttryck utan AI-anrop; AAMOS kan förfina tolkningen
* (GENERATE_RECIPE_OPTIONS) men denna bas fungerar alltid, även offline-nära.
*/
const CUISINE_KEYWORDS: Array<[Cuisine, string[]]> = [
["thai", ["thai", "thailändskt", "asiatiskt"]],
["japanese", ["japanskt", "sushi", "ramen"]],
["chinese", ["kinesiskt", "wok"]],
["korean", ["koreanskt", "kimchi"]],
["vietnamese", ["vietnamesiskt", "pho"]],
["indian", ["indiskt", "curry"]],
["italian", ["italienskt", "pasta", "pizza"]],
["french", ["franskt"]],
["spanish", ["spanskt", "tapas", "paella"]],
["greek", ["grekiskt", "gyros", "tzatziki"]],
["mexican", ["mexikanskt", "tacos", "tex mex", "texmex"]],
["american", ["amerikanskt", "burgare", "hamburgare"]],
["turkish", ["turkiskt", "kebab"]],
["lebanese", ["libanesiskt", "meze"]],
["moroccan", ["marockanskt", "tagine"]],
["swedish", ["svenskt", "husman", "husmanskost"]],
["nordic", ["nordiskt"]],
];
const TAG_KEYWORDS: Array<[string, string[]]> = [
["creamy", ["krämigt", "krämig", "gräddigt"]],
["spicy", ["starkt", "stark", "hett", "chili"]],
["fresh", ["fräscht", "fräsch", "lätt", "somrigt"]],
["comfort", ["comfort", "mysmat", "husman", "tröstmat"]],
["luxury", ["lyxigt", "lyx", "fest"]],
["quick", ["snabbt", "snabb", "enkelt", "enkel"]],
["kid_friendly", ["barnvänligt", "barnvänlig", "barn"]],
["high_protein", ["proteinrikt", "protein"]],
["vegetarian", ["vegetariskt", "vegetarisk", "vego"]],
["vegan", ["veganskt", "vegansk"]],
["soup", ["soppa"]],
["stew", ["gryta", "långkok"]],
["grill", ["grill", "grillat"]],
];
export function parseCraving(text: string): ParsedCraving {
const lower = text.toLowerCase();
const tags: string[] = [];
let cuisine: Cuisine | undefined;
for (const [c, words] of CUISINE_KEYWORDS) {
if (words.some((w) => lower.includes(w))) {
cuisine = c;
break;
}
}
for (const [tag, words] of TAG_KEYWORDS) {
if (words.some((w) => lower.includes(w))) tags.push(tag);
}
let maxKcal: number | undefined;
const kcalMatch = lower.match(/under\s+(\d{2,4})\s*(?:kcal|kalorier)/);
if (kcalMatch?.[1]) maxKcal = Number(kcalMatch[1]);
return { tags, cuisine, maxKcal };
}
@@ -0,0 +1,91 @@
import type { RecommendationCandidate, RecommendationContext } from "./types.js";
/**
* "Varför rekommenderar vi detta?" (spec §18).
* Bygger en ärlig, konkret svensk förklaring ur de deterministiska delpoängen.
* Exempel ur specen: "Ni har 92 % av ingredienserna. Kycklingen bör användas
* senast i morgon. Rätten ger 58 gram protein per portion …"
*/
export function buildWhySv(
candidate: RecommendationCandidate,
ctx: RecommendationContext,
parts: Record<string, number>,
): string {
const sentences: string[] = [];
const pct = Math.round(candidate.coverage.coverage * 100);
if (pct >= 100) {
sentences.push("Ni har alla ingredienser hemma.");
} else if (pct >= 60) {
sentences.push(`Ni har ${pct} % av ingredienserna hemma.`);
} else if (pct > 0) {
sentences.push(`Ni har ${pct} % av ingredienserna resten hamnar på inköpslistan.`);
}
const urgent = candidate.coverage.expiringUsed
.filter((m) => m.mostUrgentDaysLeft != null)
.sort((a, b) => (a.mostUrgentDaysLeft ?? 99) - (b.mostUrgentDaysLeft ?? 99))[0];
if (urgent) {
const days = urgent.mostUrgentDaysLeft ?? 0;
const when = days <= 0 ? "i dag" : days === 1 ? "senast i morgon" : `inom ${days} dagar`;
sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`);
}
const protein = Math.round(candidate.nutritionPerPortion.proteinG);
if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) {
sentences.push(`Rätten ger ${protein} gram protein per portion.`);
}
if (ctx.isTrainingDay && protein >= 35) {
sentences.push("Bra val på en träningsdag.");
}
if ((parts.rating ?? 0) >= 0.8 && candidate.householdRating != null) {
sentences.push("Liknande rätter har fått höga betyg av hushållet.");
}
if ((parts.holiday ?? 0) >= 1 && ctx.activeHolidayTags.length > 0) {
sentences.push("Passar den kommande högtiden.");
} else if ((parts.season ?? 0) >= 1) {
sentences.push("Råvarorna är i säsong just nu.");
}
if ((parts.time ?? 0) >= 1 && ctx.isWeekday) {
sentences.push(`Klar på ${candidate.totalTimeMinutes} minuter.`);
}
if ((parts.budget ?? 0) >= 1 && candidate.estimatedCostMinorPerPortion != null) {
sentences.push(
`Cirka ${Math.round(candidate.estimatedCostMinorPerPortion / 100)} kr per portion.`,
);
}
if ((parts.craving ?? 0) >= 1) {
sentences.push("Matchar det du är sugen på.");
}
if (sentences.length === 0) {
sentences.push("En balanserad rätt som passar er profil.");
}
return sentences.join(" ");
}
function capitalize(s: string): string {
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
}
/** Kompakt kontextsammanfattning (utan persondata) till AAMOS RANK_RECIPES. */
export function summarizeContext(ctx: RecommendationContext): string {
const parts = [
`måltid=${ctx.mealType}`,
`personer=${ctx.persons}`,
`säsong=${ctx.currentSeason}`,
ctx.isWeekday ? "vardag" : "helg",
];
if (ctx.maxMinutes != null) parts.push(`maxMinuter=${ctx.maxMinutes}`);
if (ctx.activeHolidayTags.length > 0) parts.push(`högtider=${ctx.activeHolidayTags.join("+")}`);
if (ctx.cravingTags && ctx.cravingTags.length > 0)
parts.push(`sugen_på=${ctx.cravingTags.join("+")}`);
if (ctx.cravingCuisine) parts.push(`kök=${ctx.cravingCuisine}`);
return parts.join(", ");
}
@@ -0,0 +1,5 @@
export * from "./types.js";
export * from "./scoring.js";
export * from "./explain.js";
export * from "./craving.js";
export * from "./season.js";
@@ -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));
}
@@ -0,0 +1,104 @@
import type { Season } from "@app/shared-types";
/**
* All kalenderaritmetik i denna modul sker i UTC (Date.UTC/getUTC*).
* Motorn ska ge identiskt resultat oavsett serverns tidszon och UTC har
* ingen sommartid, så dygnsmatte med millisekunder är alltid exakt.
*/
/** Meteorologisk årstid för norra halvklotet (SE-marknad). */
export function seasonForDate(date: Date): Season {
const m = date.getUTCMonth() + 1;
if (m >= 3 && m <= 5) return "spring";
if (m >= 6 && m <= 8) return "summer";
if (m >= 9 && m <= 11) return "autumn";
return "winter";
}
/** Midsommarafton: fredagen mellan 19 och 25 juni. */
export function midsummerEve(year: number): Date {
for (let day = 19; day <= 25; day++) {
const d = new Date(Date.UTC(year, 5, day));
if (d.getUTCDay() === 5) return d;
}
return new Date(Date.UTC(year, 5, 24));
}
/** Påskdagen enligt anonym gregoriansk algoritm. */
export function easterSunday(year: number): Date {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(Date.UTC(year, month - 1, day));
}
/** Första advent: fjärde söndagen före juldagen. */
export function firstAdvent(year: number): Date {
const christmas = new Date(Date.UTC(year, 11, 25));
const dayOfWeek = christmas.getUTCDay();
return new Date(Date.UTC(year, 11, 25 - (dayOfWeek === 0 ? 7 : dayOfWeek) - 21));
}
export interface SeasonEventRuleInput {
dateRule:
| { kind: "fixed"; monthDay: string }
| { kind: "range"; startMonthDay: string; endMonthDay: string }
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
leadDays: number;
}
/** Är eventet aktivt (inom leadDays före, till och med eventdagen/perioden)? */
export function isEventActive(rule: SeasonEventRuleInput, today: Date): boolean {
const year = today.getUTCFullYear();
const t = strip(today).getTime();
const DAY = 86_400_000;
const activeAround = (target: Date, tailDays = 1): boolean => {
const start = strip(target).getTime() - rule.leadDays * DAY;
const end = strip(target).getTime() + tailDays * DAY;
return t >= start && t <= end;
};
switch (rule.dateRule.kind) {
case "fixed": {
const [mm, dd] = rule.dateRule.monthDay.split("-").map(Number);
return activeAround(new Date(Date.UTC(year, (mm ?? 1) - 1, dd ?? 1)));
}
case "range": {
const [sm, sd] = rule.dateRule.startMonthDay.split("-").map(Number);
const [em, ed] = rule.dateRule.endMonthDay.split("-").map(Number);
const start = Date.UTC(year, (sm ?? 1) - 1, sd ?? 1) - rule.leadDays * DAY;
let end = Date.UTC(year, (em ?? 1) - 1, ed ?? 1);
if (end < start) end = Date.UTC(year + 1, (em ?? 1) - 1, ed ?? 1);
return t >= start && t <= end;
}
case "computed": {
switch (rule.dateRule.algorithm) {
case "midsummer":
return activeAround(midsummerEve(year), 2);
case "easter":
return activeAround(easterSunday(year), 1);
case "advent":
return activeAround(firstAdvent(year), 28);
case "custom":
return false;
}
}
}
}
/** UTC-dygnets start oberoende av serverns tidszon. */
function strip(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
@@ -0,0 +1,94 @@
import type {
Cuisine,
MealType,
NutritionValues,
RecipeTag,
Season,
WeatherHint,
} from "@app/shared-types";
import type { CoverageResult } from "@app/recipe-engine";
/** Kandidat som poängsätts. Säkerhetsfiltrering har redan skett (blockers borta). */
export interface RecommendationCandidate {
recipeId: string;
titleSv: string;
cuisine: Cuisine;
tags: RecipeTag[];
totalTimeMinutes: number;
nutritionPerPortion: NutritionValues;
estimatedCostMinorPerPortion?: number | null;
ratingAverage?: number | null;
ratingCount: number;
peakSeasons: Season[];
holidayTags: string[];
spiceLevel: number;
coverage: CoverageResult;
/** Dagar sedan hushållet senast lagade receptet (null = aldrig). */
daysSinceLastCooked?: number | null;
/** Hushållets snittbetyg på receptet, om finns. */
householdRating?: number | null;
}
export interface RecommendationContext {
mealType: MealType;
persons: number;
maxMinutes?: number | undefined;
maxCostMinorPerPortion?: number | undefined;
/** Proteinlucka kvar i dag (g) för den som frågar styr proteinfit. */
remainingProteinG?: number | undefined;
/** Kalorier kvar i dag recept långt över straffas mjukt. */
remainingKcal?: number | undefined;
currentSeason: Season;
activeHolidayTags: string[];
weather?: WeatherHint | undefined;
isWeekday: boolean;
isTrainingDay?: boolean | undefined;
favoriteCuisines: Cuisine[];
/** Tolkade "jag är sugen på"-taggar (spec §19), redan normaliserade. */
cravingTags?: string[] | undefined;
cravingCuisine?: Cuisine | undefined;
cravingMaxKcal?: number | undefined;
}
export interface ScoredRecommendation {
recipeId: string;
titleSv: string;
score: number;
/** Delpoäng för transparens och evals. */
parts: Record<string, number>;
whySv: string;
missingIngredients: string[];
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
coveragePercent: number;
}
/** Vikter justerbara via feature flags/admin utan koddeploy. */
export interface ScoringWeights {
coverage: number;
expiry: number;
nutritionFit: number;
taste: number;
rating: number;
season: number;
holiday: number;
time: number;
budget: number;
variety: number;
weather: number;
craving: number;
}
export const DEFAULT_WEIGHTS: ScoringWeights = {
coverage: 30,
expiry: 20,
nutritionFit: 12,
taste: 10,
rating: 8,
season: 4,
holiday: 6,
time: 8,
budget: 6,
variety: 6,
weather: 3,
craving: 15,
};