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 { idParamSchema, memoryQuerySchema, updateMemoryItemInputSchema } from "@app/validation";
|
||||||
import { errors, parse } from "../lib/errors.js";
|
import { errors, parse } from "../lib/errors.js";
|
||||||
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
|
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
|
||||||
|
import { computeMemoryImpact } from "../lib/memoryImpact.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Vad plattformen vet om mig" (spec §32): full transparens.
|
* "Vad plattformen vet om mig" (spec §32): full transparens.
|
||||||
@@ -59,6 +60,50 @@ export async function memoryRoutes(app: FastifyInstance) {
|
|||||||
return overview;
|
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) => {
|
app.patch("/v1/me/memory/:id", auth, async (req) => {
|
||||||
const { id } = parse(idParamSchema, req.params);
|
const { id } = parse(idParamSchema, req.params);
|
||||||
const input = parse(updateMemoryItemInputSchema, req.body);
|
const input = parse(updateMemoryItemInputSchema, req.body);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. */
|
/** Gruppera minnesposter för "Vad plattformen vet om mig"-vyn. */
|
||||||
export interface MemoryOverview {
|
export interface MemoryOverview {
|
||||||
language: string;
|
language: string;
|
||||||
@@ -84,7 +96,7 @@ export interface MemoryOverview {
|
|||||||
/** Rubrik på begärt språk (i18n M10). titleSv behålls för bakåtkompatibilitet. */
|
/** Rubrik på begärt språk (i18n M10). titleSv behålls för bakåtkompatibilitet. */
|
||||||
title: string;
|
title: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
items: Array<MemoryItem & { summary: string }>;
|
items: MemoryOverviewItem[];
|
||||||
}>;
|
}>;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
pausedCount: number;
|
pausedCount: number;
|
||||||
@@ -177,6 +189,76 @@ const KIND_TITLES: Record<string, Record<MemoryKind, string>> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UI_LABELS: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
origin: Record<SignalOrigin, string>;
|
||||||
|
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).
|
* 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
|
* 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 {
|
export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): MemoryOverview {
|
||||||
const lang = languageTag.split("-")[0] ?? "sv";
|
const lang = languageTag.split("-")[0] ?? "sv";
|
||||||
const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!;
|
const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!;
|
||||||
|
const labels = UI_LABELS[lang] ?? UI_LABELS.sv!;
|
||||||
const byKind = new Map<MemoryKind, MemoryItem[]>();
|
const byKind = new Map<MemoryKind, MemoryItem[]>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const list = byKind.get(item.kind) ?? [];
|
const list = byKind.get(item.kind) ?? [];
|
||||||
@@ -221,7 +304,21 @@ export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): Me
|
|||||||
titleSv: KIND_TITLES.sv![kind],
|
titleSv: KIND_TITLES.sv![kind],
|
||||||
items: list
|
items: list
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
.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 {
|
return {
|
||||||
language: lang,
|
language: lang,
|
||||||
|
|||||||
@@ -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> = {}): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user