feat(diet): lagg till nordisk/carnivore/paleo + LCHF som matvanor med mjuk rankning

- DIET_PATTERNS: +nordic, +carnivore, +paleo (migration 0029). Ingen separat
  "lchf" - LCHF tacks av befintliga "low_carb" for att undvika dubblett med
  keto/low_carb. UI visar low_carb med etiketten "LCHF".
- Mobil onboarding + profil: 9 rena val (allatare..carnivore), 12 sprak.
- recommendation-engine: ny mjuk dietAffinity-poangterm (0-1, neutral 0.5).
  Rankar recept mot matvanan - ALDRIG hart filter (uteslutande matvanor,
  religion, allergener hanteras redan deterministiskt i sakerhetsfiltret).
  low_carb/keto: lag kolhydrat. carnivore: hog protein + lag kolhydrat.
  paleo: glutenfri/laktosfri-proxyer. nordic: svenskt/nordiskt kok + sasong.
- 6 nya tester (35 grona). Full typecheck 20/20 + mobil gron.
This commit is contained in:
Claude
2026-08-18 23:03:21 +00:00
parent 3d86580cd7
commit 2587ff539b
22 changed files with 10707 additions and 2 deletions
@@ -114,6 +114,10 @@ export function scoreCandidate(
(candidate.mealTypes ?? []).includes("breakfast") || (candidate.mealTypes ?? []).includes("dessert");
parts.mealFit = mainMeal && crossover ? 0.15 : 1;
// 17. Diet-affinitet (spec §diet): mjuk rankning mot matvanan. Aldrig ett hårt
// filter uteslutande matvanor (vegan m.fl.) hanteras i säkerhetsfiltret.
parts.dietAffinity = dietAffinityFit(candidate, ctx);
const score = weightedSum(parts, weights);
return {
@@ -216,6 +220,64 @@ function cravingFit(candidate: RecommendationCandidate, ctx: RecommendationConte
return checks === 0 ? 0.5 : hits / checks;
}
/**
* Mjuk diet-affinitet (spec §diet). Rankar recept som passar användarens matvana
* högre men aldrig ett hårt filter: uteslutande matvanor (vegan/vegetarian/
* pescetarian), religion och allergener hanteras redan deterministiskt i
* säkerhetsfiltret före poängsättning. Returnerar 01 med 0.5 som neutralt
* utgångsläge när vi saknar signal (belägger aldrig ett straff vi inte kan visa
* matchar den stödjande, icke-restriktiva hälsoprofilen, R7).
*/
function dietAffinityFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
const diet = ctx.dietPattern;
if (!diet) return 0.5;
const { proteinG, carbsG } = candidate.nutritionPerPortion;
const tags = new Set<string>(candidate.tags);
switch (diet) {
case "low_carb":
case "keto": {
// LCHF/keto: låg kolhydrat premieras. Explicit low_carb-tagg = säkrast.
if (tags.has("low_carb")) return 1;
if (carbsG <= 15) return 1;
if (carbsG <= 30) return 0.75;
if (carbsG <= 50) return 0.45;
return 0.15;
}
case "carnivore": {
// Djurbaserat: hög proteinandel + mycket låg kolhydrat (macro-proxy). Vi
// saknar ingrediens-kategoriflaggor, så ingen hård uteslutning salt och
// kryddor ska aldrig straffas.
const lowCarb = carbsG <= 10 ? 1 : carbsG <= 25 ? 0.5 : 0.15;
const highProtein = proteinG >= 35 ? 1 : proteinG >= 20 ? 0.6 : 0.3;
return clamp01(lowCarb * 0.6 + highProtein * 0.4);
}
case "paleo": {
// Paleo utesluter spannmål/baljväxter/mejeri/raffinerat socker. Exakta
// kategoriflaggor saknas -> tillförlitliga proxyer: glutenfri- och
// laktosfri-taggar är positiva signaler. Utan signal: neutral (0.5).
let score = 0.5;
if (tags.has("gluten_free")) score += 0.2;
if (tags.has("lactose_free")) score += 0.15;
if (tags.has("low_carb")) score += 0.1;
return clamp01(score);
}
case "nordic": {
// Nordisk: svenskt/nordiskt kök + säsong premieras.
const regional = candidate.cuisine === "swedish" || candidate.cuisine === "nordic";
const inSeason = candidate.peakSeasons.includes(ctx.currentSeason);
if (regional && inSeason) return 1;
if (regional) return 0.85;
if (inSeason) return 0.55;
return 0.4;
}
default:
// omnivore/flexitarian/mediterranean/high_protein + veg-mönster: neutral.
// (Veg-mönster hårdfiltreras redan; ingen rankningsskillnad behövs här.)
return 0.5;
}
}
interface FitResult {
score: number;
provenance: ProvenanceEntry[];
@@ -1,5 +1,6 @@
import type {
Cuisine,
DietPattern,
MealType,
MemoryItem,
NutritionValues,
@@ -63,6 +64,12 @@ export interface RecommendationContext {
isWeekday: boolean;
isTrainingDay?: boolean | undefined;
favoriteCuisines: Cuisine[];
/**
* Användarens matvana (spec §diet). Styr enbart MJUK rankning (dietAffinity)
* hårda uteslutningar (vegan/vegetarian/pescetarian/religion/allergener) sker
* redan i säkerhetsfiltret före poängsättning. undefined = ingen preferens.
*/
dietPattern?: DietPattern | undefined;
/** Tolkade "jag är sugen på"-taggar (spec §19), redan normaliserade. */
cravingTags?: string[] | undefined;
cravingCuisine?: Cuisine | undefined;
@@ -106,6 +113,8 @@ export interface ScoringWeights {
weather: number;
craving: number;
mealFit: number;
/** Mjuk diet-affinitet (spec §diet) rankar recept som passar matvanan högre. */
dietAffinity: number;
/** Nya S1-vikter; 0 om personalisering är av. */
memoryFit: number;
tasteFit: number;
@@ -126,6 +135,7 @@ export const DEFAULT_WEIGHTS: ScoringWeights = {
weather: 3,
craving: 15,
mealFit: 12,
dietAffinity: 10,
memoryFit: 6,
tasteFit: 6,
cookingAssumptionFit: 4,
@@ -159,6 +169,7 @@ export const TASTE_VIEW_WEIGHTS: ScoringWeights = {
weather: 1,
craving: 25,
mealFit: 12,
dietAffinity: 8,
memoryFit: 12,
tasteFit: 12,
cookingAssumptionFit: 6,
@@ -179,6 +190,7 @@ export const HEALTH_VIEW_WEIGHTS: ScoringWeights = {
weather: 2,
craving: 5,
mealFit: 12,
dietAffinity: 14,
memoryFit: 3,
tasteFit: 3,
cookingAssumptionFit: 4,
@@ -199,6 +211,7 @@ export const PANTRY_VIEW_WEIGHTS: ScoringWeights = {
weather: 1,
craving: 5,
mealFit: 12,
dietAffinity: 8,
memoryFit: 4,
tasteFit: 4,
cookingAssumptionFit: 8,
@@ -486,3 +486,72 @@ describe("S4 rekommendationsvyer", () => {
expect(tasteWeights.taste).toBe(TASTE_VIEW_WEIGHTS.taste);
});
});
describe("diet-affinitet (spec §diet, mjuk rankning)", () => {
it("utan matvana är affiniteten neutral (0.5)", () => {
const scored = scoreCandidate(candidate(), ctx);
expect(scored.parts.dietAffinity).toBe(0.5);
});
it("low_carb/LCHF premierar låg kolhydrat", () => {
const low = scoreCandidate(candidate({ nutritionPerPortion: { ...nutrition, carbsG: 10 } }), {
...ctx,
dietPattern: "low_carb",
});
const high = scoreCandidate(candidate({ nutritionPerPortion: { ...nutrition, carbsG: 60 } }), {
...ctx,
dietPattern: "low_carb",
});
expect(low.parts.dietAffinity).toBe(1);
expect(high.parts.dietAffinity).toBe(0.15);
});
it("explicit low_carb-tagg räcker oavsett makro", () => {
const tagged = scoreCandidate(
candidate({ tags: ["low_carb"], nutritionPerPortion: { ...nutrition, carbsG: 55 } }),
{ ...ctx, dietPattern: "keto" },
);
expect(tagged.parts.dietAffinity).toBe(1);
});
it("carnivore premierar högt protein + låg kolhydrat", () => {
const meaty = scoreCandidate(
candidate({ nutritionPerPortion: { ...nutrition, proteinG: 45, carbsG: 5 } }),
{ ...ctx, dietPattern: "carnivore" },
);
const carby = scoreCandidate(
candidate({ nutritionPerPortion: { ...nutrition, proteinG: 12, carbsG: 70 } }),
{ ...ctx, dietPattern: "carnivore" },
);
expect(meaty.parts.dietAffinity).toBe(1);
expect(meaty.parts.dietAffinity).toBeGreaterThan(carby.parts.dietAffinity ?? 0);
});
it("nordisk premierar svenskt/nordiskt kök", () => {
const swedish = scoreCandidate(candidate({ cuisine: "swedish" }), {
...ctx,
dietPattern: "nordic",
});
const italian = scoreCandidate(candidate({ cuisine: "italian" }), {
...ctx,
dietPattern: "nordic",
});
expect(swedish.parts.dietAffinity).toBe(0.85);
expect(swedish.parts.dietAffinity).toBeGreaterThan(italian.parts.dietAffinity ?? 0);
});
it("är mjuk rankning, inte hårt filter ej matchande recept finns kvar", () => {
const lowCarbRecipe = candidate({
recipeId: "lowcarb",
nutritionPerPortion: { ...nutrition, carbsG: 8 },
});
const pastaRecipe = candidate({
recipeId: "pasta",
nutritionPerPortion: { ...nutrition, carbsG: 80 },
});
const ranked = rankAll([pastaRecipe, lowCarbRecipe], { ...ctx, dietPattern: "low_carb" });
// Båda recepten finns kvar (inget filtreras bort), men lågkolhydrat rankas högre.
expect(ranked).toHaveLength(2);
expect(ranked[0]?.recipeId).toBe("lowcarb");
});
});
+7
View File
@@ -140,6 +140,13 @@ export const DIET_PATTERNS = [
"keto",
"high_protein",
"mediterranean",
// Tillagda (spec §diet): nordiskt, carnivore, paleo. Additivt gamla värden
// orörda, ingen dataförlust. Rankas/AI-styrs snarare än hårdfiltreras.
// OBS: ingen separat "lchf" LCHF täcks av befintliga "low_carb" (undviker
// dubblett med keto/low_carb). UI visar "low_carb" med etiketten "LCHF".
"nordic",
"carnivore",
"paleo",
] as const;
export type DietPattern = (typeof DIET_PATTERNS)[number];