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.
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoverageResult } from "@app/recipe-engine";
|
||||
import type { MemoryItem, TasteSignal } from "@app/shared-types";
|
||||
import {
|
||||
containsForbiddenCopy,
|
||||
DEFAULT_WEIGHTS,
|
||||
easterSunday,
|
||||
isEventActive,
|
||||
midsummerEve,
|
||||
NON_PERSONALIZED_WEIGHTS,
|
||||
parseCraving,
|
||||
rankAll,
|
||||
renderProvenance,
|
||||
scoreCandidate,
|
||||
seasonForDate,
|
||||
type CookingAssumption,
|
||||
type RecommendationCandidate,
|
||||
type RecommendationContext,
|
||||
} from "../src/index.js";
|
||||
@@ -52,6 +58,7 @@ const ctx: RecommendationContext = {
|
||||
favoriteCuisines: ["swedish"],
|
||||
remainingProteinG: 60,
|
||||
remainingKcal: 800,
|
||||
personalizationEnabled: false,
|
||||
};
|
||||
|
||||
describe("poängsättning (spec §18)", () => {
|
||||
@@ -157,3 +164,168 @@ describe("säsongs- och eventmotor (spec §28)", () => {
|
||||
expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("S1 personalisering", () => {
|
||||
const memoryBase: MemoryItem = {
|
||||
id: "m1",
|
||||
userId: "u1",
|
||||
kind: "structured_fact",
|
||||
key: "favorite_cuisine_swedish",
|
||||
summarySv: "Gillar svensk mat",
|
||||
value: { favoriteCuisine: "swedish" },
|
||||
origin: "user_stated",
|
||||
confidence: 1,
|
||||
verifiedByUser: true,
|
||||
paused: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const aiMemory: MemoryItem = {
|
||||
...memoryBase,
|
||||
id: "m2",
|
||||
origin: "ai_inferred",
|
||||
confidence: 0.5,
|
||||
verifiedByUser: false,
|
||||
value: { favoriteCuisine: "swedish" },
|
||||
};
|
||||
|
||||
const tastePositive: TasteSignal = {
|
||||
id: "t1",
|
||||
userId: "u1",
|
||||
axis: "spice",
|
||||
direction: 1,
|
||||
strength: 0.8,
|
||||
origin: "user_stated",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const tasteNegative: TasteSignal = {
|
||||
...tastePositive,
|
||||
id: "t2",
|
||||
direction: -1,
|
||||
};
|
||||
|
||||
const cookingAssumption: CookingAssumption = {
|
||||
canonicalIngredientId: "chicken",
|
||||
averageEatenPortions: 4,
|
||||
averageLeftoverPortions: 0.5,
|
||||
observationCount: 5,
|
||||
};
|
||||
|
||||
it("memoryFit boostar verified user_stated högre än ai_inferred", () => {
|
||||
const verifiedCtx: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [memoryBase],
|
||||
tasteSignals: [],
|
||||
cookingAssumptions: [],
|
||||
};
|
||||
const aiCtx: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [aiMemory],
|
||||
tasteSignals: [],
|
||||
cookingAssumptions: [],
|
||||
};
|
||||
const verified = scoreCandidate(candidate({ cuisine: "swedish" }), verifiedCtx);
|
||||
const ai = scoreCandidate(candidate({ cuisine: "swedish" }), aiCtx);
|
||||
expect(verified.parts.memoryFit ?? 0).toBeGreaterThan(ai.parts.memoryFit ?? 0);
|
||||
});
|
||||
|
||||
it("tasteFit ger positiv riktning för positiv signal", () => {
|
||||
const spicyCandidate = candidate({ spiceLevel: 3 });
|
||||
const positiveCtx: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [],
|
||||
tasteSignals: [tastePositive],
|
||||
cookingAssumptions: [],
|
||||
};
|
||||
const negativeCtx: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [],
|
||||
tasteSignals: [tasteNegative],
|
||||
cookingAssumptions: [],
|
||||
};
|
||||
const positive = scoreCandidate(spicyCandidate, positiveCtx);
|
||||
const negative = scoreCandidate(spicyCandidate, negativeCtx);
|
||||
expect(positive.parts.tasteFit ?? 0).toBeGreaterThan(negative.parts.tasteFit ?? 0);
|
||||
});
|
||||
|
||||
it("cookingAssumptionFit boostar ingrediens som hushållet brukar äta upp", () => {
|
||||
const ctxWithAssumption: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [],
|
||||
tasteSignals: [],
|
||||
cookingAssumptions: [cookingAssumption],
|
||||
};
|
||||
const boosted = scoreCandidate(
|
||||
candidate({ ingredientIds: ["chicken", "onion"] }),
|
||||
ctxWithAssumption,
|
||||
);
|
||||
const noMatch = scoreCandidate(
|
||||
candidate({ ingredientIds: ["beef", "onion"] }),
|
||||
ctxWithAssumption,
|
||||
);
|
||||
expect(boosted.parts.cookingAssumptionFit ?? 0).toBeGreaterThan(
|
||||
noMatch.parts.cookingAssumptionFit ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("utan personalizationEnabled är personliga delpoäng noll", () => {
|
||||
const noPersonalizationCtx: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: false,
|
||||
memoryItems: [memoryBase],
|
||||
tasteSignals: [tastePositive],
|
||||
cookingAssumptions: [cookingAssumption],
|
||||
};
|
||||
const scored = scoreCandidate(
|
||||
candidate({ cuisine: "swedish", spiceLevel: 3 }),
|
||||
noPersonalizationCtx,
|
||||
);
|
||||
expect(scored.parts.memoryFit).toBe(0);
|
||||
expect(scored.parts.tasteFit).toBe(0);
|
||||
expect(scored.parts.cookingAssumptionFit).toBe(0);
|
||||
});
|
||||
|
||||
it("proveniens renderas från mall med grundade fakta", () => {
|
||||
const rendered = renderProvenance(
|
||||
[{ key: "favoriteCuisine", args: { cuisine: "svensk" } }],
|
||||
"sv-SE",
|
||||
);
|
||||
expect(rendered).toContain("svensk");
|
||||
expect(rendered).toContain("berättat");
|
||||
});
|
||||
|
||||
it("förbjuden copy upptäcks", () => {
|
||||
expect(containsForbiddenCopy("Du har överskridit ditt kalorimål")).toBe(true);
|
||||
expect(containsForbiddenCopy("Passar ditt proteinmål")).toBe(false);
|
||||
});
|
||||
|
||||
it("scoring är deterministisk", () => {
|
||||
const personalCtx: RecommendationContext = {
|
||||
...ctx,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: [memoryBase],
|
||||
tasteSignals: [tastePositive],
|
||||
cookingAssumptions: [cookingAssumption],
|
||||
};
|
||||
const c = candidate({ ingredientIds: ["chicken"], spiceLevel: 3 });
|
||||
const a = scoreCandidate(c, personalCtx);
|
||||
const b = scoreCandidate(c, personalCtx);
|
||||
expect(a.score).toBe(b.score);
|
||||
expect(a.whySv).toBe(b.whySv);
|
||||
});
|
||||
|
||||
it("icke-personaliserade vikter har noll för S1-komponenter", () => {
|
||||
expect(NON_PERSONALIZED_WEIGHTS.memoryFit).toBe(0);
|
||||
expect(NON_PERSONALIZED_WEIGHTS.tasteFit).toBe(0);
|
||||
expect(NON_PERSONALIZED_WEIGHTS.cookingAssumptionFit).toBe(0);
|
||||
// Befintliga vikter ska vara oförändrade.
|
||||
expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user