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,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
import type { MemoryItem, TasteSignal } from "@app/shared-types";
|
||||
import { whatToEatQuerySchema } from "@app/validation";
|
||||
import {
|
||||
computeCoverage,
|
||||
@@ -11,10 +12,12 @@ import {
|
||||
} from "@app/recipe-engine";
|
||||
import {
|
||||
isEventActive,
|
||||
NON_PERSONALIZED_WEIGHTS,
|
||||
parseCraving,
|
||||
rankAll,
|
||||
seasonForDate,
|
||||
summarizeContext,
|
||||
type CookingAssumption,
|
||||
type RecommendationCandidate,
|
||||
type RecommendationContext,
|
||||
} from "@app/recommendation-engine";
|
||||
@@ -216,7 +219,94 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
.groupBy(schema.recipeRatings.recipeId);
|
||||
const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));
|
||||
|
||||
// --- 7. Tolka "jag är sugen på" (spec §19) ---
|
||||
// --- 7. Personaliseringssamtycke — HÅRD GRIND (S1) ---
|
||||
const [personalizationConsent] = await app.db
|
||||
.select()
|
||||
.from(schema.userConsents)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userConsents.userId, req.userId),
|
||||
eq(schema.userConsents.kind, "personalization"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const personalizationEnabled = personalizationConsent?.status === "granted";
|
||||
|
||||
let memoryItems: MemoryItem[] = [];
|
||||
let tasteSignals: TasteSignal[] = [];
|
||||
let cookingAssumptions: CookingAssumption[] = [];
|
||||
|
||||
if (personalizationEnabled && householdId) {
|
||||
const memoryRows = await app.db
|
||||
.select({
|
||||
id: schema.memoryItems.id,
|
||||
userId: schema.memoryItems.userId,
|
||||
householdId: schema.memoryItems.householdId,
|
||||
kind: schema.memoryItems.kind,
|
||||
key: schema.memoryItems.key,
|
||||
summarySv: schema.memoryItems.summarySv,
|
||||
value: schema.memoryItems.value,
|
||||
origin: schema.memoryItems.origin,
|
||||
confidence: schema.memoryItems.confidence,
|
||||
verifiedByUser: schema.memoryItems.verifiedByUser,
|
||||
paused: schema.memoryItems.paused,
|
||||
createdAt: schema.memoryItems.createdAt,
|
||||
updatedAt: schema.memoryItems.updatedAt,
|
||||
})
|
||||
.from(schema.memoryItems)
|
||||
.where(
|
||||
or(
|
||||
eq(schema.memoryItems.userId, req.userId),
|
||||
eq(schema.memoryItems.householdId, householdId),
|
||||
),
|
||||
);
|
||||
memoryItems = memoryRows.map((m) => ({
|
||||
...m,
|
||||
userId: m.userId ?? undefined,
|
||||
householdId: m.householdId ?? undefined,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
lastUsedAt: undefined,
|
||||
expiresAt: undefined,
|
||||
}));
|
||||
|
||||
const tasteRows = await app.db
|
||||
.select({
|
||||
id: schema.tasteSignals.id,
|
||||
userId: schema.tasteSignals.userId,
|
||||
axis: schema.tasteSignals.axis,
|
||||
direction: schema.tasteSignals.direction,
|
||||
strength: schema.tasteSignals.strength,
|
||||
origin: schema.tasteSignals.origin,
|
||||
refRecipeId: schema.tasteSignals.refRecipeId,
|
||||
createdAt: schema.tasteSignals.createdAt,
|
||||
})
|
||||
.from(schema.tasteSignals)
|
||||
.where(eq(schema.tasteSignals.userId, req.userId));
|
||||
tasteSignals = tasteRows.map((t) => ({
|
||||
...t,
|
||||
refRecipeId: t.refRecipeId ?? undefined,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const assumptions = await app.db
|
||||
.select({
|
||||
canonicalIngredientId: schema.cookingAssumptionProfiles.canonicalIngredientId,
|
||||
averageEatenPortions: schema.cookingAssumptionProfiles.averageEatenPortions,
|
||||
averageLeftoverPortions: schema.cookingAssumptionProfiles.averageLeftoverPortions,
|
||||
observationCount: schema.cookingAssumptionProfiles.observationCount,
|
||||
})
|
||||
.from(schema.cookingAssumptionProfiles)
|
||||
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
cookingAssumptions = assumptions.map((a) => ({
|
||||
canonicalIngredientId: a.canonicalIngredientId,
|
||||
averageEatenPortions: a.averageEatenPortions,
|
||||
averageLeftoverPortions: a.averageLeftoverPortions,
|
||||
observationCount: a.observationCount,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- 8. Tolka "jag är sugen på" (spec §19) ---
|
||||
const craving = q.craving ? parseCraving(q.craving) : null;
|
||||
|
||||
const ctx: RecommendationContext = {
|
||||
@@ -233,6 +323,10 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
cravingTags: craving?.tags,
|
||||
cravingCuisine: craving?.cuisine,
|
||||
cravingMaxKcal: craving?.maxKcal,
|
||||
personalizationEnabled,
|
||||
memoryItems: personalizationEnabled ? memoryItems : undefined,
|
||||
tasteSignals: personalizationEnabled ? tasteSignals : undefined,
|
||||
cookingAssumptions: personalizationEnabled ? cookingAssumptions : undefined,
|
||||
};
|
||||
|
||||
// --- 8. Filtrera säkert + beräkna täckning + poängsätt ---
|
||||
@@ -287,10 +381,12 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
|
||||
: null,
|
||||
householdRating: householdRatingMap.get(recipe.id) ?? null,
|
||||
ingredientIds: recipeIngredients.map((i) => i.canonicalIngredientId).filter((id): id is string => id != null),
|
||||
});
|
||||
}
|
||||
|
||||
let recommendations = rankAll(scoredCandidates, ctx, undefined, q.limit);
|
||||
const weights = personalizationEnabled ? undefined : NON_PERSONALIZED_WEIGHTS;
|
||||
let recommendations = rankAll(scoredCandidates, ctx, weights, q.limit);
|
||||
|
||||
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
|
||||
if (await app.flags.isEnabled("ai_rerank", req.userId)) {
|
||||
|
||||
@@ -113,6 +113,72 @@ describe("what-to-eat without household", () => {
|
||||
expect(prefs?.primaryGoal).toBe("cook_more");
|
||||
});
|
||||
|
||||
it("personalization is gated by consent: no provenance without granted consent", async () => {
|
||||
const registerRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/auth/register",
|
||||
payload: { email: "personalization-gate@example.invalid", password: "Password123!", displayName: "Gate" },
|
||||
});
|
||||
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
||||
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/onboarding/quick-start",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { goals: ["cook_more"], persons: 2, precisionMode: "simple" },
|
||||
});
|
||||
|
||||
// Without personalization consent: no personal signals read, no provenance.
|
||||
const withoutConsent = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/recommendations/what-to-eat?limit=5",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(withoutConsent.statusCode).toBe(200);
|
||||
const bodyWithout = JSON.parse(withoutConsent.body) as {
|
||||
recommendations: Array<{ provenance?: unknown[]; whySv: string }>;
|
||||
};
|
||||
expect(bodyWithout.recommendations.length).toBeGreaterThan(0);
|
||||
for (const r of bodyWithout.recommendations) {
|
||||
expect(r.provenance ?? []).toHaveLength(0);
|
||||
expect(r.whySv).not.toContain("berättat");
|
||||
}
|
||||
|
||||
// Grant personalization consent.
|
||||
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?limit=5",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(withConsent.statusCode).toBe(200);
|
||||
const bodyWith = JSON.parse(withConsent.body) as {
|
||||
recommendations: Array<{ recipeId: string; provenance?: unknown[]; score: number }>;
|
||||
};
|
||||
expect(bodyWith.recommendations.length).toBeGreaterThan(0);
|
||||
// Consent alone does not guarantee provenance; it just enables the path.
|
||||
// We verify determinism: same call twice = same order.
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/recommendations/what-to-eat?limit=5",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
const bodySecond = JSON.parse(second.body) as {
|
||||
recommendations: Array<{ recipeId: string; score: number }>;
|
||||
};
|
||||
expect(bodyWith.recommendations.map((r) => r.recipeId)).toEqual(
|
||||
bodySecond.recommendations.map((r) => r.recipeId),
|
||||
);
|
||||
});
|
||||
|
||||
it("quick-start auto-creates a household with default storage locations", async () => {
|
||||
const registerRes = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user