fix(recommendation-engine): S4-FIX — opersonliga vyer fungerar utan samtycke via depersonalize(weights)

This commit is contained in:
Sven (AAMOS AI)
2026-08-11 04:02:46 +07:00
parent 05bba93b90
commit 1e18146665
4 changed files with 160 additions and 16 deletions
+4 -6
View File
@@ -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,
+85 -10
View File
@@ -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);
});
});