feat(memory): S3 3a minnes-yta + impact-endpoint

This commit is contained in:
Sven (AAMOS AI)
2026-08-11 02:41:39 +07:00
parent 4296a64aef
commit 335cfe0bf6
5 changed files with 873 additions and 2 deletions
+431
View File
@@ -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),
};
}
+45
View File
@@ -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);
+216
View File
@@ -0,0 +1,216 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { and, eq, inArray } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
const emails = {
patch: "memory-patch@example.invalid",
consent: "memory-consent@example.invalid",
privacyA: "memory-privacy-a@example.invalid",
privacyB: "memory-privacy-b@example.invalid",
};
describe("/v1/me/memory", () => {
let app: Awaited<ReturnType<typeof buildServer>>;
beforeAll(async () => {
app = await buildServer(config);
await app.ready();
await cleanupAll();
});
afterAll(async () => {
await cleanupAll();
await closeDatabase();
await app.close();
});
async function cleanupAll() {
const allEmails = Object.values(emails);
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(inArray(schema.users.email, allEmails));
for (const u of existing) {
await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id));
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
const ownedHouseholds = await testDb.db
.select({ id: schema.households.id })
.from(schema.households)
.innerJoin(
schema.householdMembers,
eq(schema.householdMembers.householdId, schema.households.id),
)
.where(
and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")),
);
for (const h of ownedHouseholds) {
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id));
await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id));
}
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
async function registerUser(email: string) {
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "Memory Test" },
});
const body = JSON.parse(res.body) as { accessToken: string };
const token = body.accessToken;
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"], precisionMode: "simple" },
});
return { token, userId };
}
it("PATCH /v1/me/memory/:id ändrar origin till user_stated", async () => {
const { token, userId } = await registerUser(emails.patch);
const [item] = await testDb.db
.insert(schema.memoryItems)
.values({
userId,
kind: "structured_fact",
key: "likes-pasta",
summarySv: "Gillar pasta",
origin: "ai_inferred",
confidence: 0.5,
})
.returning();
const patchRes = await app.inject({
method: "PATCH",
url: `/v1/me/memory/${item!.id}`,
headers: { authorization: `Bearer ${token}` },
payload: { summarySv: "Gillar verkligen pasta" },
});
expect(patchRes.statusCode).toBe(200);
const body = JSON.parse(patchRes.body) as { origin: string; confidence: number; verifiedByUser: boolean };
expect(body.origin).toBe("user_stated");
expect(body.confidence).toBe(1);
expect(body.verifiedByUser).toBe(true);
});
it("GET /v1/me/memory/:id/impact returnerar tom lista utan personalization-samtycke", async () => {
const { token, userId } = await registerUser(emails.consent);
const [item] = await testDb.db
.insert(schema.memoryItems)
.values({
userId,
kind: "structured_fact",
key: "favorite-cuisine-thai",
summarySv: "Gillar thaimat",
value: { favoriteCuisine: "thai" },
origin: "user_stated",
confidence: 1,
})
.returning();
const res = await app.inject({
method: "GET",
url: `/v1/me/memory/${item!.id}/impact`,
headers: { authorization: `Bearer ${token}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body) as { error: { code: string } };
expect(body.error.code).toBe("PERSONALIZATION_CONSENT_REQUIRED");
});
it("impact för användare A returnerar aldrig användare B:s data", async () => {
const userA = await registerUser(emails.privacyA);
const userB = await registerUser(emails.privacyB);
await testDb.db.insert(schema.userConsents).values([
{ userId: userA.userId, kind: "personalization", status: "granted" },
{ userId: userB.userId, kind: "personalization", status: "granted" },
]);
const [itemA] = await testDb.db
.insert(schema.memoryItems)
.values({
userId: userA.userId,
kind: "structured_fact",
key: "favorite-cuisine-thai",
summarySv: "Gillar thaimat",
value: { favoriteCuisine: "thai" },
origin: "user_stated",
confidence: 1,
})
.returning();
await testDb.db.insert(schema.memoryItems).values({
userId: userB.userId,
kind: "structured_fact",
key: "favorite-cuisine-italian",
summarySv: "Gillar italienskt",
value: { favoriteCuisine: "italian" },
origin: "user_stated",
confidence: 1,
});
const res = await app.inject({
method: "GET",
url: `/v1/me/memory/${itemA!.id}/impact`,
headers: { authorization: `Bearer ${userA.token}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as {
memoryItemId: string;
impacted: Array<{ whySv: string }>;
};
expect(body.memoryItemId).toBe(itemA!.id);
// Varje varat whySv ska vara från befintliga mallar och får inte avslöja B:s data.
for (const rec of body.impacted) {
expect(rec.whySv).not.toContain("italian");
expect(rec.whySv).not.toContain("italienskt");
}
});
});
describe("memory i18n parity", () => {
it("alla 12 språk har origin, paused och guess utan saknade/dubbletter", async () => {
// Dynamisk import för att slippa cirkulärt beroende i testsetup.
const { buildMemoryOverview } = await import("@app/memory-client");
const { SUPPORTED_LANGUAGE_TAGS } = await import("@app/shared-types");
const item = {
id: "m1",
userId: "u1",
kind: "structured_fact" as const,
key: "k1",
summarySv: "Sammanfattning",
value: { favoriteCuisine: "thai" },
origin: "ai_inferred" as const,
confidence: 0.5,
verifiedByUser: false,
paused: true,
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
};
for (const tag of SUPPORTED_LANGUAGE_TAGS) {
const overview = buildMemoryOverview([item], tag);
const rendered = overview.sections[0]!.items[0]!;
expect(rendered.originLabel.length).toBeGreaterThan(0);
expect(rendered.pausedLabel).toBeTruthy();
expect(rendered.guessLabel).toBeTruthy();
}
});
});