From 335cfe0bf64e41a99f3c8caecd5ccab8125a1879 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Tue, 11 Aug 2026 02:41:39 +0700 Subject: [PATCH] feat(memory): S3 3a minnes-yta + impact-endpoint --- apps/api/src/lib/memoryImpact.ts | 431 +++++++++++++++++++ apps/api/src/routes/memory.ts | 45 ++ apps/api/test/memory.test.ts | 216 ++++++++++ packages/memory-client/src/index.ts | 101 ++++- packages/memory-client/test/overview.test.ts | 82 ++++ 5 files changed, 873 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/lib/memoryImpact.ts create mode 100644 apps/api/test/memory.test.ts create mode 100644 packages/memory-client/test/overview.test.ts diff --git a/apps/api/src/lib/memoryImpact.ts b/apps/api/src/lib/memoryImpact.ts new file mode 100644 index 0000000..ff4b2eb --- /dev/null +++ b/apps/api/src/lib/memoryImpact.ts @@ -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 }>; + }>; +} + +/** + * 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 { + 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( + 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`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`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), + }; +} diff --git a/apps/api/src/routes/memory.ts b/apps/api/src/routes/memory.ts index b12e506..4c6dc08 100644 --- a/apps/api/src/routes/memory.ts +++ b/apps/api/src/routes/memory.ts @@ -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); diff --git a/apps/api/test/memory.test.ts b/apps/api/test/memory.test.ts new file mode 100644 index 0000000..bd32daf --- /dev/null +++ b/apps/api/test/memory.test.ts @@ -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>; + + 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(); + } + }); +}); diff --git a/packages/memory-client/src/index.ts b/packages/memory-client/src/index.ts index cdcd793..d904f04 100644 --- a/packages/memory-client/src/index.ts +++ b/packages/memory-client/src/index.ts @@ -76,6 +76,18 @@ export async function deriveMemoryUpdates( })); } +export interface MemoryOverviewItem extends MemoryItem { + summary: string; + /** Lokaliserad etikett för ursprung (user_stated/observed/ai_inferred). */ + originLabel: string; + /** Konfidens 0–100 %. */ + confidencePercent: number; + /** Synlig etikett om posten är pausad. */ + pausedLabel?: string; + /** Tydlig gissningsmarkering för ai_inferred (R1). */ + guessLabel?: string; +} + /** Gruppera minnesposter för "Vad plattformen vet om mig"-vyn. */ export interface MemoryOverview { language: string; @@ -84,7 +96,7 @@ export interface MemoryOverview { /** Rubrik på begärt språk (i18n M10). titleSv behålls för bakåtkompatibilitet. */ title: string; titleSv: string; - items: Array; + items: MemoryOverviewItem[]; }>; totalCount: number; pausedCount: number; @@ -177,6 +189,76 @@ const KIND_TITLES: Record> = { }, }; +const UI_LABELS: Record< + string, + { + origin: Record; + paused: string; + guess: string; + } +> = { + sv: { + origin: { user_stated: "Du har sagt", observed: "Vi har sett", ai_inferred: "Gissning" }, + paused: "Pausad", + guess: "Detta är en gissning – bekräfta eller ändra om det stämmer.", + }, + en: { + origin: { user_stated: "You said", observed: "We noticed", ai_inferred: "Guess" }, + paused: "Paused", + guess: "This is a guess — confirm or change it if it fits.", + }, + es: { + origin: { user_stated: "Dijiste", observed: "Notamos", ai_inferred: "Suposición" }, + paused: "Pausado", + guess: "Esto es una suposición: confírmala o cámbiala si encaja.", + }, + it: { + origin: { user_stated: "Hai detto", observed: "Abbiamo notato", ai_inferred: "Ipotesi" }, + paused: "In pausa", + guess: "Questa è un'ipotesi: confermala o modificala se è corretta.", + }, + de: { + origin: { user_stated: "Du hast gesagt", observed: "Wir haben bemerkt", ai_inferred: "Vermutung" }, + paused: "Pausiert", + guess: "Dies ist eine Vermutung: bestätige oder ändere sie, wenn sie stimmt.", + }, + fr: { + origin: { user_stated: "Vous avez dit", observed: "Nous avons remarqué", ai_inferred: "Hypothèse" }, + paused: "En pause", + guess: "Ceci est une hypothèse : confirmez ou modifiez si cela vous convient.", + }, + da: { + origin: { user_stated: "Du har sagt", observed: "Vi har set", ai_inferred: "Gæt" }, + paused: "Pauset", + guess: "Dette er et gæt — bekræft eller ændr det, hvis det passer.", + }, + nb: { + origin: { user_stated: "Du har sagt", observed: "Vi har sett", ai_inferred: "Gjettning" }, + paused: "Pauset", + guess: "Dette er en gjetning — bekreft eller endre hvis det stemmer.", + }, + fi: { + origin: { user_stated: "Olet sanonut", observed: "Olemme huomanneet", ai_inferred: "Arvaus" }, + paused: "Tauolla", + guess: "Tämä on arvaus: vahvista tai muuta se, jos se sopii.", + }, + nl: { + origin: { user_stated: "Jij zei", observed: "We hebben gezien", ai_inferred: "Gok" }, + paused: "Gepauzeerd", + guess: "Dit is een gok: bevestig of pas aan als het klopt.", + }, + pl: { + origin: { user_stated: "Powiedziałeś", observed: "Zauważyliśmy", ai_inferred: "Domysł" }, + paused: "Wstrzymane", + guess: "To jest przypuszczenie — potwierdź lub zmień, jeśli się zgadza.", + }, + pt: { + origin: { user_stated: "Disseste", observed: "Reparámos", ai_inferred: "Suposição" }, + paused: "Pausado", + guess: "Isto é uma suposição: confirma ou altera se fizer sentido.", + }, +}; + /** * Deterministisk rendering av summary ur strukturerad value (i18n-spec §23, M10). * Kända value-former renderas per språk; okända faller tillbaka på summarySv @@ -209,6 +291,7 @@ export function renderMemorySummary( export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): MemoryOverview { const lang = languageTag.split("-")[0] ?? "sv"; const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!; + const labels = UI_LABELS[lang] ?? UI_LABELS.sv!; const byKind = new Map(); for (const item of items) { const list = byKind.get(item.kind) ?? []; @@ -221,7 +304,21 @@ export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): Me titleSv: KIND_TITLES.sv![kind], items: list .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - .map((i) => ({ ...i, summary: renderMemorySummary(i, languageTag) })), + .map((i) => { + const overviewItem: MemoryOverviewItem = { + ...i, + summary: renderMemorySummary(i, languageTag), + originLabel: labels.origin[i.origin] ?? labels.origin.ai_inferred, + confidencePercent: Math.round(i.confidence * 100), + }; + if (i.paused) { + overviewItem.pausedLabel = labels.paused; + } + if (i.origin === "ai_inferred") { + overviewItem.guessLabel = labels.guess; + } + return overviewItem; + }), })); return { language: lang, diff --git a/packages/memory-client/test/overview.test.ts b/packages/memory-client/test/overview.test.ts new file mode 100644 index 0000000..22c6e7e --- /dev/null +++ b/packages/memory-client/test/overview.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildMemoryOverview } from "../src/index.js"; +import type { MemoryItem } from "@app/shared-types"; + +function makeItem(overrides: Partial = {}): MemoryItem { + return { + id: "m1", + userId: "u1", + kind: "structured_fact", + key: "favorite_cuisine_swedish", + summarySv: "Gillar svensk mat", + value: { favoriteCuisine: "swedish" }, + origin: "user_stated", + confidence: 1, + verifiedByUser: true, + paused: false, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildMemoryOverview", () => { + it("grupperar minnen efter kind", () => { + const items: MemoryItem[] = [ + makeItem({ id: "a", kind: "structured_fact", summarySv: "Fakta A" }), + makeItem({ id: "b", kind: "recipe_memory", summarySv: "Recept B" }), + makeItem({ id: "c", kind: "structured_fact", summarySv: "Fakta C" }), + ]; + const overview = buildMemoryOverview(items, "sv-SE"); + const kinds = overview.sections.map((s) => s.kind); + expect(kinds).toContain("structured_fact"); + expect(kinds).toContain("recipe_memory"); + const factSection = overview.sections.find((s) => s.kind === "structured_fact")!; + expect(factSection.items).toHaveLength(2); + }); + + it("visar origin och confidence tydligt", () => { + const items: MemoryItem[] = [ + makeItem({ origin: "observed", confidence: 0.75 }), + ]; + const overview = buildMemoryOverview(items, "sv-SE"); + const item = overview.sections[0]!.items[0]!; + expect(item.originLabel).toBe("Vi har sett"); + expect(item.confidencePercent).toBe(75); + }); + + it("markerar pausade poster synligt", () => { + const items: MemoryItem[] = [makeItem({ paused: true })]; + const overview = buildMemoryOverview(items, "sv-SE"); + const item = overview.sections[0]!.items[0]!; + expect(item.pausedLabel).toBe("Pausad"); + expect(overview.pausedCount).toBe(1); + }); + + it("markerar ai_inferred som gissning", () => { + const items: MemoryItem[] = [makeItem({ origin: "ai_inferred", confidence: 0.5 })]; + const overview = buildMemoryOverview(items, "sv-SE"); + const item = overview.sections[0]!.items[0]!; + expect(item.originLabel).toBe("Gissning"); + expect(item.guessLabel).toBe("Detta är en gissning – bekräfta eller ändra om det stämmer."); + }); + + it("sorterar senast uppdaterade överst", () => { + const items: MemoryItem[] = [ + makeItem({ id: "old", updatedAt: "2026-08-01T00:00:00.000Z" }), + makeItem({ id: "new", updatedAt: "2026-08-10T00:00:00.000Z" }), + ]; + const overview = buildMemoryOverview(items, "sv-SE"); + expect(overview.sections[0]!.items[0]!.id).toBe("new"); + expect(overview.sections[0]!.items[1]!.id).toBe("old"); + }); + + it("stödjer engelska texter", () => { + const items: MemoryItem[] = [makeItem({ origin: "user_stated", paused: true })]; + const overview = buildMemoryOverview(items, "en-US"); + const item = overview.sections[0]!.items[0]!; + expect(item.originLabel).toBe("You said"); + expect(item.pausedLabel).toBe("Paused"); + expect(item.summary).toBe("Favorite cuisine: swedish"); + }); +});