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)) {
|
||||
|
||||
Reference in New Issue
Block a user