diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index e607596..6fc6a6c 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -11,8 +11,8 @@ import { type PantryItem, } from "@app/recipe-engine"; import { + depersonalize, isEventActive, - NON_PERSONALIZED_WEIGHTS, parseCraving, rankAll, seasonForDate, @@ -389,10 +389,8 @@ export async function recommendationRoutes(app: FastifyInstance) { } const weights = personalizationEnabled - ? q.view === "default" - ? undefined - : viewWeights(q.view) - : NON_PERSONALIZED_WEIGHTS; + ? viewWeights(q.view) + : depersonalize(viewWeights(q.view)); let recommendations = rankAll(scoredCandidates, ctx, weights, q.limit); // --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) --- @@ -457,7 +455,7 @@ export async function recommendationRoutes(app: FastifyInstance) { remainingKcal: ctx.remainingKcal, remainingProteinG: ctx.remainingProteinG, craving: craving ?? null, - view: personalizationEnabled ? q.view : "default", + view: q.view, }, mealBoxSuggestions, recommendations, diff --git a/apps/api/test/recommendations.test.ts b/apps/api/test/recommendations.test.ts index 1f51630..d872cdb 100644 --- a/apps/api/test/recommendations.test.ts +++ b/apps/api/test/recommendations.test.ts @@ -310,7 +310,7 @@ describe("S4 recommendation views", () => { } }); - it("ignores view and falls back to default without personalization consent", async () => { + it("honoreras vy och nollade personliga axlar utan samtycke", async () => { // Revoke consent. await testDb.db .insert(schema.userConsents) @@ -322,30 +322,105 @@ describe("S4 recommendation views", () => { const defaultRes = await app.inject({ method: "GET", - url: "/v1/recommendations/what-to-eat?limit=1", + url: "/v1/recommendations/what-to-eat?limit=5", headers: { authorization: `Bearer ${accessToken}` }, }); - const viewRes = await app.inject({ + const pantryRes = await app.inject({ method: "GET", - url: "/v1/recommendations/what-to-eat?view=taste&limit=1", + url: "/v1/recommendations/what-to-eat?view=pantry&limit=5", + headers: { authorization: `Bearer ${accessToken}` }, + }); + const healthRes = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?view=health&limit=5", + headers: { authorization: `Bearer ${accessToken}` }, + }); + const tasteRes = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?view=taste&limit=5", headers: { authorization: `Bearer ${accessToken}` }, }); expect(defaultRes.statusCode).toBe(200); - expect(viewRes.statusCode).toBe(200); + expect(pantryRes.statusCode).toBe(200); + expect(healthRes.statusCode).toBe(200); + expect(tasteRes.statusCode).toBe(200); const defaultBody = JSON.parse(defaultRes.body) as { context: { view: string }; + recommendations: Array<{ recipeId: string; parts: Record }>; + }; + const pantryBody = JSON.parse(pantryRes.body) as { + context: { view: string }; + recommendations: Array<{ recipeId: string; parts: Record }>; + }; + const healthBody = JSON.parse(healthRes.body) as { + context: { view: string }; + recommendations: Array<{ recipeId: string; parts: Record }>; + }; + const tasteBody = JSON.parse(tasteRes.body) as { + context: { view: string }; + recommendations: Array<{ recipeId: string; parts: Record }>; + }; + + // Vyn honoreras i svaret även utan samtycke. + expect(defaultBody.context.view).toBe("default"); + expect(pantryBody.context.view).toBe("pantry"); + expect(healthBody.context.view).toBe("health"); + expect(tasteBody.context.view).toBe("taste"); + + // Personliga axlar är nollade i alla vyer utan samtycke. + for (const body of [defaultBody, pantryBody, healthBody, tasteBody]) { + for (const rec of body.recommendations) { + expect(rec.parts.memoryFit ?? 0).toBe(0); + expect(rec.parts.tasteFit ?? 0).toBe(0); + expect(rec.parts.cookingAssumptionFit ?? 0).toBe(0); + } + } + }); + + it("default-vyn är oförändrad med och utan samtycke", async () => { + // Se till att samtycke är revoked. + await testDb.db + .insert(schema.userConsents) + .values({ userId, kind: "personalization", status: "revoked" }) + .onConflictDoUpdate({ + target: [schema.userConsents.userId, schema.userConsents.kind], + set: { status: "revoked" }, + }); + + const withoutConsent = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?view=default&limit=5", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + await testDb.db + .insert(schema.userConsents) + .values({ userId, kind: "personalization", status: "granted" }) + .onConflictDoUpdate({ + target: [schema.userConsents.userId, schema.userConsents.kind], + set: { status: "granted" }, + }); + + const withConsent = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?view=default&limit=5", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(withoutConsent.statusCode).toBe(200); + expect(withConsent.statusCode).toBe(200); + + const a = JSON.parse(withoutConsent.body) as { recommendations: Array<{ recipeId: string }>; }; - const viewBody = JSON.parse(viewRes.body) as { - context: { view: string }; + const b = JSON.parse(withConsent.body) as { recommendations: Array<{ recipeId: string }>; }; - expect(viewBody.context.view).toBe("default"); - expect(viewBody.recommendations.map((r) => r.recipeId)).toEqual( - defaultBody.recommendations.map((r) => r.recipeId), + expect(a.recommendations.map((r) => r.recipeId)).toEqual( + b.recommendations.map((r) => r.recipeId), ); }); }); diff --git a/packages/recommendation-engine/src/types.ts b/packages/recommendation-engine/src/types.ts index 2715947..766b290 100644 --- a/packages/recommendation-engine/src/types.ts +++ b/packages/recommendation-engine/src/types.ts @@ -211,3 +211,8 @@ export function viewWeights(view: RecommendationView): ScoringWeights { return DEFAULT_WEIGHTS; } } + +/** Nollställ de personliga axlarna i en viktmängd — används när samtycke saknas. */ +export function depersonalize(weights: ScoringWeights): ScoringWeights { + return { ...weights, memoryFit: 0, tasteFit: 0, cookingAssumptionFit: 0 }; +} diff --git a/packages/recommendation-engine/test/engine.test.ts b/packages/recommendation-engine/test/engine.test.ts index 24960b0..c5127b2 100644 --- a/packages/recommendation-engine/test/engine.test.ts +++ b/packages/recommendation-engine/test/engine.test.ts @@ -4,6 +4,7 @@ import type { MemoryItem, TasteSignal } from "@app/shared-types"; import { containsForbiddenCopy, DEFAULT_WEIGHTS, + depersonalize, easterSunday, HEALTH_VIEW_WEIGHTS, isEventActive, @@ -403,4 +404,69 @@ describe("S4 rekommendationsvyer", () => { expect(viewWeights("health").nutritionFit).toBe(HEALTH_VIEW_WEIGHTS.nutritionFit); expect(viewWeights("pantry").coverage).toBe(PANTRY_VIEW_WEIGHTS.coverage); }); + + it("opersonliga vyerna fungerar utan samtycke genom depersonalize", () => { + const nonPersonalCtx: RecommendationContext = { + ...viewCtx, + personalizationEnabled: false, + memoryItems: undefined, + tasteSignals: undefined, + cookingAssumptions: undefined, + }; + + const tasty = candidate({ + recipeId: "tasty", + titleSv: "Svensk köttbullsgryta", + cuisine: "swedish", + coverage: fullCoverage, + nutritionPerPortion: { ...nutrition, proteinG: 20 }, + }); + + const pantry = candidate({ + recipeId: "pantry", + titleSv: "Italiensk pastarätt", + cuisine: "italian", + coverage: { + ...fullCoverage, + expiringUsed: [ + { + canonicalIngredientId: "pasta", + displayNameSv: "pastan", + required: 200, + unit: "GRAM", + availableInUnit: 250, + covered: true, + optional: false, + mostUrgentDaysLeft: 1, + usesExpiringItem: true, + }, + ], + }, + nutritionPerPortion: { ...nutrition, proteinG: 15 }, + }); + + const healthy = candidate({ + recipeId: "healthy", + titleSv: "Kyckling och quinoa", + cuisine: "greek", + coverage: fullCoverage, + nutritionPerPortion: { ...nutrition, proteinG: 50 }, + }); + + // default utan samtycke = depersonalize(DEFAULT_WEIGHTS) == NON_PERSONALIZED_WEIGHTS + const defaultRanked = rankAll([pantry, healthy, tasty], nonPersonalCtx, depersonalize(viewWeights("default"))); + const nonPersonalDefaultRanked = rankAll([pantry, healthy, tasty], nonPersonalCtx, NON_PERSONALIZED_WEIGHTS); + expect(defaultRanked.map((r) => r.recipeId)).toEqual(nonPersonalDefaultRanked.map((r) => r.recipeId)); + + // vyerna är fortfarande skilda från default även utan samtycke + expect(rankAll([tasty, healthy, pantry], nonPersonalCtx, depersonalize(viewWeights("pantry")))[0]?.recipeId).toBe("pantry"); + expect(rankAll([tasty, pantry, healthy], nonPersonalCtx, depersonalize(viewWeights("health")))[0]?.recipeId).toBe("healthy"); + + // taste-vyn honoreras men personliga axlar är nollade + const tasteWeights = depersonalize(viewWeights("taste")); + expect(tasteWeights.memoryFit).toBe(0); + expect(tasteWeights.tasteFit).toBe(0); + expect(tasteWeights.cookingAssumptionFit).toBe(0); + expect(tasteWeights.taste).toBe(TASTE_VIEW_WEIGHTS.taste); + }); });