fix(recommendation-engine): S4-FIX — opersonliga vyer fungerar utan samtycke via depersonalize(weights)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, number> }>;
|
||||
};
|
||||
const pantryBody = JSON.parse(pantryRes.body) as {
|
||||
context: { view: string };
|
||||
recommendations: Array<{ recipeId: string; parts: Record<string, number> }>;
|
||||
};
|
||||
const healthBody = JSON.parse(healthRes.body) as {
|
||||
context: { view: string };
|
||||
recommendations: Array<{ recipeId: string; parts: Record<string, number> }>;
|
||||
};
|
||||
const tasteBody = JSON.parse(tasteRes.body) as {
|
||||
context: { view: string };
|
||||
recommendations: Array<{ recipeId: string; parts: Record<string, number> }>;
|
||||
};
|
||||
|
||||
// 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user