feat(memory): S3 3a minnes-yta + impact-endpoint
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { schema, type Database } from "@app/database";
|
||||
import type { MemoryItem, TasteSignal } from "@app/shared-types";
|
||||
import {
|
||||
computeCoverage,
|
||||
checkRecipeSafety,
|
||||
isRecipeSafe,
|
||||
type IngredientSafetyInfo,
|
||||
type PantryItem,
|
||||
} from "@app/recipe-engine";
|
||||
import {
|
||||
isEventActive,
|
||||
parseCraving,
|
||||
rankAll,
|
||||
seasonForDate,
|
||||
viewWeights,
|
||||
type CookingAssumption,
|
||||
type RecommendationCandidate,
|
||||
type RecommendationContext,
|
||||
type RecommendationView,
|
||||
type ScoredRecommendation,
|
||||
} from "@app/recommendation-engine";
|
||||
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
|
||||
import { getActiveHouseholdId, todayIso } from "./helpers.js";
|
||||
|
||||
export interface MemoryImpactOptions {
|
||||
db: Database;
|
||||
userId: string;
|
||||
memoryItem: MemoryItem;
|
||||
mealType?: string;
|
||||
persons?: number;
|
||||
maxMinutes?: number;
|
||||
craving?: string;
|
||||
view?: "default" | "taste" | "health" | "pantry";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface MemoryImpactResult {
|
||||
memoryItemId: string;
|
||||
personalizationEnabled: boolean;
|
||||
impacted: Array<{
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
scoreWith: number;
|
||||
scoreWithout: number;
|
||||
delta: number;
|
||||
whySv: string;
|
||||
provenance: Array<{ key: string; args: Record<string, string | number> }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berätta vilka rekommendationer ett specifikt minne påverkar.
|
||||
*
|
||||
* - Kräver personalization-samtycke (R2).
|
||||
* - Använder ENDAST den anropande användarens egna minnen/smaksignaler;
|
||||
* andra hushållsmedlemmars personliga data blir aldrig lästa eller returnerade.
|
||||
* - Återanvänder rankAll/scoreCandidate från recommendation-engine.
|
||||
*/
|
||||
export async function computeMemoryImpact(options: MemoryImpactOptions): Promise<MemoryImpactResult> {
|
||||
const { db, userId, memoryItem, mealType = "dinner", persons, maxMinutes, craving, view = "default", limit = 10 } = options;
|
||||
|
||||
// R2: personalization-samtycke krävs för impact.
|
||||
const [consent] = await db
|
||||
.select()
|
||||
.from(schema.userConsents)
|
||||
.where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "personalization")))
|
||||
.limit(1);
|
||||
if (consent?.status !== "granted") {
|
||||
return { memoryItemId: memoryItem.id, personalizationEnabled: false, impacted: [] };
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const householdId = await getActiveHouseholdId(db, userId);
|
||||
|
||||
// --- 1. Lager ---
|
||||
const stockRows = householdId
|
||||
? await db
|
||||
.select({
|
||||
item: schema.inventoryItems,
|
||||
locationType: schema.storageLocations.type,
|
||||
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
|
||||
density: schema.canonicalIngredients.densityGPerMl,
|
||||
gramsPerPiece: schema.canonicalIngredients.gramsPerPiece,
|
||||
})
|
||||
.from(schema.inventoryItems)
|
||||
.innerJoin(
|
||||
schema.storageLocations,
|
||||
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.canonicalIngredients,
|
||||
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
isNull(schema.inventoryItems.depletedAt),
|
||||
gt(schema.inventoryItems.quantity, 0),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
const pantry: PantryItem[] = stockRows.map((r) => ({
|
||||
id: r.item.id,
|
||||
canonicalIngredientId: r.item.canonicalIngredientId,
|
||||
quantity: r.item.quantity,
|
||||
unit: r.item.unit,
|
||||
bestBeforeDate: r.item.bestBeforeDate,
|
||||
useByDate: r.item.useByDate,
|
||||
openedAt: r.item.openedAt,
|
||||
frozenAt: r.item.frozenAt,
|
||||
thawedAt: r.item.thawedAt,
|
||||
purchasedAt: r.item.purchasedAt,
|
||||
storageLocationType: r.locationType,
|
||||
shelfLifeGuidance: r.shelfLife,
|
||||
}));
|
||||
const unitInfo = new Map(
|
||||
stockRows
|
||||
.filter((r) => r.item.canonicalIngredientId)
|
||||
.map((r) => [
|
||||
r.item.canonicalIngredientId!,
|
||||
{ densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece },
|
||||
]),
|
||||
);
|
||||
|
||||
// --- 2. Hushållets samlade begränsningar (allergener etc är hushållsgemensamma) ---
|
||||
const members = householdId
|
||||
? await db
|
||||
.select({ userId: schema.householdMembers.userId })
|
||||
.from(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.householdId, householdId))
|
||||
: [];
|
||||
const memberIds = members.length ? members.map((m) => m.userId) : [userId];
|
||||
const allPrefs = await db
|
||||
.select()
|
||||
.from(schema.userPreferences)
|
||||
.where(inArray(schema.userPreferences.userId, memberIds));
|
||||
|
||||
const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))];
|
||||
const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))];
|
||||
const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
|
||||
const myPrefs = allPrefs.find((p) => p.userId === userId);
|
||||
|
||||
// --- 3. Kandidater ---
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(schema.recipes)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recipes.status, "published"),
|
||||
sql`${mealType} = ANY(${schema.recipes.mealTypes})`,
|
||||
),
|
||||
)
|
||||
.limit(200);
|
||||
|
||||
const allIngredients = await db
|
||||
.select()
|
||||
.from(schema.recipeIngredients)
|
||||
.where(
|
||||
inArray(
|
||||
schema.recipeIngredients.recipeId,
|
||||
candidates.map((c) => c.id),
|
||||
),
|
||||
);
|
||||
const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))];
|
||||
const safetyRows = await db
|
||||
.select()
|
||||
.from(schema.canonicalIngredients)
|
||||
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
|
||||
const safetyMap = new Map<string, IngredientSafetyInfo>(
|
||||
safetyRows.map((r) => [
|
||||
r.id,
|
||||
{
|
||||
id: r.id,
|
||||
allergens: r.allergens,
|
||||
isVegan: r.isVegan,
|
||||
isVegetarian: r.isVegetarian,
|
||||
containsGluten: r.containsGluten,
|
||||
containsLactose: r.containsLactose,
|
||||
isPork: r.isPork,
|
||||
isBeef: r.isBeef,
|
||||
isAlcohol: r.isAlcohol,
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const r of safetyRows) {
|
||||
if (!unitInfo.has(r.id)) {
|
||||
unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Näringskontext ---
|
||||
const [profile] = await db
|
||||
.select()
|
||||
.from(schema.userHealthProfiles)
|
||||
.where(eq(schema.userHealthProfiles.userId, userId))
|
||||
.limit(1);
|
||||
const targets =
|
||||
profile?.weightKg && profile.heightCm && profile.birthYear
|
||||
? computeDailyTargets({
|
||||
sex: profile.sex ?? "unspecified",
|
||||
age: today.getUTCFullYear() - profile.birthYear,
|
||||
heightCm: profile.heightCm,
|
||||
weightKg: profile.weightKg,
|
||||
activityLevel: profile.activityLevel,
|
||||
primaryGoal: myPrefs?.primaryGoal ?? undefined,
|
||||
}).targets
|
||||
: DEFAULT_TARGETS;
|
||||
const todaysMeals = await db
|
||||
.select({ nutrition: schema.meals.nutrition })
|
||||
.from(schema.meals)
|
||||
.where(and(eq(schema.meals.userId, userId), eq(schema.meals.date, todayIso())));
|
||||
const daySummary = summarizeDay(
|
||||
todaysMeals.map((m) => m.nutrition),
|
||||
targets,
|
||||
);
|
||||
|
||||
// --- 5. Säsong & högtid ---
|
||||
const events = await db
|
||||
.select()
|
||||
.from(schema.seasonEvents)
|
||||
.where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, "SE")));
|
||||
const activeHolidayTags = events
|
||||
.filter((e) => isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today))
|
||||
.map((e) => e.slug);
|
||||
|
||||
// --- 6. Historik + betyg ---
|
||||
const cooks = householdId
|
||||
? await db
|
||||
.select({
|
||||
recipeId: schema.recipeCooks.recipeId,
|
||||
last: sql<string>`max(${schema.recipeCooks.cookedAt})`,
|
||||
})
|
||||
.from(schema.recipeCooks)
|
||||
.where(eq(schema.recipeCooks.householdId, householdId))
|
||||
.groupBy(schema.recipeCooks.recipeId)
|
||||
: [];
|
||||
const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last]));
|
||||
const householdRatings = await db
|
||||
.select({
|
||||
recipeId: schema.recipeRatings.recipeId,
|
||||
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
|
||||
})
|
||||
.from(schema.recipeRatings)
|
||||
.where(inArray(schema.recipeRatings.userId, memberIds))
|
||||
.groupBy(schema.recipeRatings.recipeId);
|
||||
const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));
|
||||
|
||||
// --- 7. Personliga signaler — ENDAST anroparens egna (R2 + integritet) ---
|
||||
const memoryRows = await 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(eq(schema.memoryItems.userId, userId));
|
||||
const userMemoryItems: MemoryItem[] = 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 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, userId));
|
||||
const tasteSignals: TasteSignal[] = tasteRows.map((t) => ({
|
||||
...t,
|
||||
refRecipeId: t.refRecipeId ?? undefined,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
let cookingAssumptions: CookingAssumption[] = [];
|
||||
if (householdId) {
|
||||
const assumptions = await 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. Bygg kandidater ---
|
||||
const scoredCandidates: RecommendationCandidate[] = [];
|
||||
for (const recipe of candidates) {
|
||||
const recipeIngredients = allIngredients
|
||||
.filter((i) => i.recipeId === recipe.id)
|
||||
.map((i) => ({
|
||||
canonicalIngredientId: i.canonicalIngredientId,
|
||||
displayNameSv: i.displayNameSv,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
optional: i.optional,
|
||||
}));
|
||||
|
||||
const violations = checkRecipeSafety(
|
||||
{
|
||||
ingredients: recipeIngredients.map((i) => ({
|
||||
canonicalIngredientId: i.canonicalIngredientId,
|
||||
optional: i.optional,
|
||||
})),
|
||||
spiceLevel: recipe.spiceLevel,
|
||||
},
|
||||
{
|
||||
allergens: combinedAllergens,
|
||||
dietPattern: myPrefs?.dietPattern,
|
||||
religiousRule: myPrefs?.religiousRule,
|
||||
avoidIngredientIds: combinedAvoid,
|
||||
spiceLevelMax: strictestSpice,
|
||||
},
|
||||
safetyMap,
|
||||
);
|
||||
if (!isRecipeSafe(violations)) continue;
|
||||
|
||||
const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today);
|
||||
const lastDate = lastCooked.get(recipe.id);
|
||||
scoredCandidates.push({
|
||||
recipeId: recipe.id,
|
||||
titleSv: recipe.titleSv,
|
||||
cuisine: recipe.cuisine,
|
||||
tags: recipe.tags as never,
|
||||
totalTimeMinutes: recipe.totalTimeMinutes,
|
||||
nutritionPerPortion: recipe.nutritionPerPortion,
|
||||
estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion,
|
||||
ratingAverage: recipe.ratingAverage,
|
||||
ratingCount: recipe.ratingCount,
|
||||
peakSeasons: recipe.peakSeasons,
|
||||
holidayTags: recipe.holidayTags,
|
||||
spiceLevel: recipe.spiceLevel,
|
||||
coverage,
|
||||
daysSinceLastCooked: lastDate
|
||||
? 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),
|
||||
});
|
||||
}
|
||||
|
||||
const parsedCraving = craving ? parseCraving(craving) : null;
|
||||
const baseCtx: RecommendationContext = {
|
||||
mealType: mealType as never,
|
||||
persons: persons ?? (members.length ? members.length : 1),
|
||||
maxMinutes,
|
||||
remainingProteinG: Math.max(0, daySummary.remaining.proteinG),
|
||||
remainingKcal: Math.max(0, daySummary.remaining.kcal),
|
||||
currentSeason: seasonForDate(today),
|
||||
activeHolidayTags,
|
||||
isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4,
|
||||
favoriteCuisines: myPrefs?.favoriteCuisines ?? [],
|
||||
cravingTags: parsedCraving?.tags,
|
||||
cravingCuisine: parsedCraving?.cuisine,
|
||||
cravingMaxKcal: parsedCraving?.maxKcal,
|
||||
personalizationEnabled: true,
|
||||
memoryItems: userMemoryItems,
|
||||
tasteSignals,
|
||||
cookingAssumptions,
|
||||
};
|
||||
|
||||
const weights = view === "default" ? undefined : viewWeights(view as RecommendationView);
|
||||
// Använd default-vikter för impact eftersom vi vill se minnets effekt i normalfallet.
|
||||
const withMemory = rankAll(scoredCandidates, baseCtx, weights, limit * 2);
|
||||
|
||||
const withoutCtx: RecommendationContext = {
|
||||
...baseCtx,
|
||||
memoryItems: userMemoryItems.filter((m) => m.id !== memoryItem.id),
|
||||
};
|
||||
const withoutMemory = rankAll(scoredCandidates, withoutCtx, weights, limit * 2);
|
||||
|
||||
const withoutById = new Map(withoutMemory.map((r) => [r.recipeId, r]));
|
||||
const impacted: MemoryImpactResult["impacted"] = [];
|
||||
|
||||
for (const rec of withMemory) {
|
||||
const without = withoutById.get(rec.recipeId);
|
||||
if (!without) continue;
|
||||
const delta = Math.round((rec.score - without.score) * 10) / 10;
|
||||
if (delta === 0) continue;
|
||||
impacted.push({
|
||||
recipeId: rec.recipeId,
|
||||
titleSv: rec.titleSv,
|
||||
scoreWith: rec.score,
|
||||
scoreWithout: without.score,
|
||||
delta,
|
||||
whySv: rec.whySv,
|
||||
provenance: rec.provenance,
|
||||
});
|
||||
}
|
||||
|
||||
impacted.sort((a, b) => b.delta - a.delta);
|
||||
|
||||
return {
|
||||
memoryItemId: memoryItem.id,
|
||||
personalizationEnabled: true,
|
||||
impacted: impacted.slice(0, limit),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { userLanguageTag } from "../lib/contentLanguage.js";
|
||||
import { idParamSchema, memoryQuerySchema, updateMemoryItemInputSchema } from "@app/validation";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
|
||||
import { computeMemoryImpact } from "../lib/memoryImpact.js";
|
||||
|
||||
/**
|
||||
* "Vad plattformen vet om mig" (spec §32): full transparens.
|
||||
@@ -59,6 +60,50 @@ export async function memoryRoutes(app: FastifyInstance) {
|
||||
return overview;
|
||||
});
|
||||
|
||||
/**
|
||||
* Visa vilka rekommendationer ett specifikt minne påverkar.
|
||||
* Kräver personalization-samtycke; isolerat till den anropande användaren.
|
||||
*/
|
||||
app.get("/v1/me/memory/:id/impact", auth, async (req, reply) => {
|
||||
const { id } = parse(idParamSchema, req.params);
|
||||
const item = await getOwnedMemory(app, id, req.userId);
|
||||
|
||||
const memoryItem: import("@app/shared-types").MemoryItem = {
|
||||
id: item.id,
|
||||
userId: item.userId ?? undefined,
|
||||
householdId: item.householdId ?? undefined,
|
||||
kind: item.kind,
|
||||
key: item.key,
|
||||
summarySv: item.summarySv,
|
||||
value: item.value,
|
||||
origin: item.origin,
|
||||
confidence: item.confidence,
|
||||
verifiedByUser: item.verifiedByUser,
|
||||
paused: item.paused,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
lastUsedAt: item.lastUsedAt?.toISOString(),
|
||||
expiresAt: item.expiresAt?.toISOString(),
|
||||
};
|
||||
|
||||
const result = await computeMemoryImpact({
|
||||
db: app.db,
|
||||
userId: req.userId,
|
||||
memoryItem,
|
||||
});
|
||||
|
||||
if (!result.personalizationEnabled) {
|
||||
return reply.status(403).send({
|
||||
error: {
|
||||
code: "PERSONALIZATION_CONSENT_REQUIRED",
|
||||
message: "Impact kräver personalization-samtycke.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
app.patch("/v1/me/memory/:id", auth, async (req) => {
|
||||
const { id } = parse(idParamSchema, req.params);
|
||||
const input = parse(updateMemoryItemInputSchema, req.body);
|
||||
|
||||
Reference in New Issue
Block a user