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:
Sven (AAMOS AI)
2026-08-10 03:23:31 +07:00
parent 16a7849e71
commit 0885f5bceb
8 changed files with 767 additions and 9 deletions
+99 -3
View File
@@ -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)) {