feat(recommendation-engine): S1 personalisering för 'Vad ska vi äta?'

- Mallbaserad proveniens i 12 språk (inga fria AI-texter i rekommendationer).
- memoryFit, tasteFit, cookingAssumptionFit endast vid personalization-samtycke.
- Hård grind i API:et: läser memory_items/taste_signals/cooking_assumption_profiles
  endast när userConsents.personalization = granted.
- NON_PERSONALIZED_WEIGHTS bevarar existerande beteende vid avsaknad av samtycke.
- Positiv, icke-restriktiv näringscopy (R7).
- Deterministisk scoring + enhetstester för S1.
- Integrationstest som verifierar provenans-gate med/utan samtycke.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 03:23:31 +07:00
parent 16a7849e71
commit 0885f5bceb
8 changed files with 767 additions and 9 deletions
+99 -3
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { schema } from "@app/database"; import { schema } from "@app/database";
import type { MemoryItem, TasteSignal } from "@app/shared-types";
import { whatToEatQuerySchema } from "@app/validation"; import { whatToEatQuerySchema } from "@app/validation";
import { import {
computeCoverage, computeCoverage,
@@ -11,10 +12,12 @@ import {
} from "@app/recipe-engine"; } from "@app/recipe-engine";
import { import {
isEventActive, isEventActive,
NON_PERSONALIZED_WEIGHTS,
parseCraving, parseCraving,
rankAll, rankAll,
seasonForDate, seasonForDate,
summarizeContext, summarizeContext,
type CookingAssumption,
type RecommendationCandidate, type RecommendationCandidate,
type RecommendationContext, type RecommendationContext,
} from "@app/recommendation-engine"; } from "@app/recommendation-engine";
@@ -216,7 +219,94 @@ export async function recommendationRoutes(app: FastifyInstance) {
.groupBy(schema.recipeRatings.recipeId); .groupBy(schema.recipeRatings.recipeId);
const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)])); const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));
// --- 7. Tolka "jag är sugen på" (spec §19) --- // --- 7. Personaliseringssamtycke — HÅRD GRIND (S1) ---
const [personalizationConsent] = await app.db
.select()
.from(schema.userConsents)
.where(
and(
eq(schema.userConsents.userId, req.userId),
eq(schema.userConsents.kind, "personalization"),
),
)
.limit(1);
const personalizationEnabled = personalizationConsent?.status === "granted";
let memoryItems: MemoryItem[] = [];
let tasteSignals: TasteSignal[] = [];
let cookingAssumptions: CookingAssumption[] = [];
if (personalizationEnabled && householdId) {
const memoryRows = await app.db
.select({
id: schema.memoryItems.id,
userId: schema.memoryItems.userId,
householdId: schema.memoryItems.householdId,
kind: schema.memoryItems.kind,
key: schema.memoryItems.key,
summarySv: schema.memoryItems.summarySv,
value: schema.memoryItems.value,
origin: schema.memoryItems.origin,
confidence: schema.memoryItems.confidence,
verifiedByUser: schema.memoryItems.verifiedByUser,
paused: schema.memoryItems.paused,
createdAt: schema.memoryItems.createdAt,
updatedAt: schema.memoryItems.updatedAt,
})
.from(schema.memoryItems)
.where(
or(
eq(schema.memoryItems.userId, req.userId),
eq(schema.memoryItems.householdId, householdId),
),
);
memoryItems = memoryRows.map((m) => ({
...m,
userId: m.userId ?? undefined,
householdId: m.householdId ?? undefined,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
lastUsedAt: undefined,
expiresAt: undefined,
}));
const tasteRows = await app.db
.select({
id: schema.tasteSignals.id,
userId: schema.tasteSignals.userId,
axis: schema.tasteSignals.axis,
direction: schema.tasteSignals.direction,
strength: schema.tasteSignals.strength,
origin: schema.tasteSignals.origin,
refRecipeId: schema.tasteSignals.refRecipeId,
createdAt: schema.tasteSignals.createdAt,
})
.from(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, req.userId));
tasteSignals = tasteRows.map((t) => ({
...t,
refRecipeId: t.refRecipeId ?? undefined,
createdAt: t.createdAt.toISOString(),
}));
const assumptions = await app.db
.select({
canonicalIngredientId: schema.cookingAssumptionProfiles.canonicalIngredientId,
averageEatenPortions: schema.cookingAssumptionProfiles.averageEatenPortions,
averageLeftoverPortions: schema.cookingAssumptionProfiles.averageLeftoverPortions,
observationCount: schema.cookingAssumptionProfiles.observationCount,
})
.from(schema.cookingAssumptionProfiles)
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
cookingAssumptions = assumptions.map((a) => ({
canonicalIngredientId: a.canonicalIngredientId,
averageEatenPortions: a.averageEatenPortions,
averageLeftoverPortions: a.averageLeftoverPortions,
observationCount: a.observationCount,
}));
}
// --- 8. Tolka "jag är sugen på" (spec §19) ---
const craving = q.craving ? parseCraving(q.craving) : null; const craving = q.craving ? parseCraving(q.craving) : null;
const ctx: RecommendationContext = { const ctx: RecommendationContext = {
@@ -233,6 +323,10 @@ export async function recommendationRoutes(app: FastifyInstance) {
cravingTags: craving?.tags, cravingTags: craving?.tags,
cravingCuisine: craving?.cuisine, cravingCuisine: craving?.cuisine,
cravingMaxKcal: craving?.maxKcal, cravingMaxKcal: craving?.maxKcal,
personalizationEnabled,
memoryItems: personalizationEnabled ? memoryItems : undefined,
tasteSignals: personalizationEnabled ? tasteSignals : undefined,
cookingAssumptions: personalizationEnabled ? cookingAssumptions : undefined,
}; };
// --- 8. Filtrera säkert + beräkna täckning + poängsätt --- // --- 8. Filtrera säkert + beräkna täckning + poängsätt ---
@@ -287,10 +381,12 @@ export async function recommendationRoutes(app: FastifyInstance) {
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000) ? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
: null, : null,
householdRating: householdRatingMap.get(recipe.id) ?? null, householdRating: householdRatingMap.get(recipe.id) ?? null,
ingredientIds: recipeIngredients.map((i) => i.canonicalIngredientId).filter((id): id is string => id != null),
}); });
} }
let recommendations = rankAll(scoredCandidates, ctx, undefined, q.limit); const weights = personalizationEnabled ? undefined : NON_PERSONALIZED_WEIGHTS;
let recommendations = rankAll(scoredCandidates, ctx, weights, q.limit);
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) --- // --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
if (await app.flags.isEnabled("ai_rerank", req.userId)) { if (await app.flags.isEnabled("ai_rerank", req.userId)) {
+66
View File
@@ -113,6 +113,72 @@ describe("what-to-eat without household", () => {
expect(prefs?.primaryGoal).toBe("cook_more"); expect(prefs?.primaryGoal).toBe("cook_more");
}); });
it("personalization is gated by consent: no provenance without granted consent", async () => {
const registerRes = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email: "personalization-gate@example.invalid", password: "Password123!", displayName: "Gate" },
});
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
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"], persons: 2, precisionMode: "simple" },
});
// Without personalization consent: no personal signals read, no provenance.
const withoutConsent = await app.inject({
method: "GET",
url: "/v1/recommendations/what-to-eat?limit=5",
headers: { authorization: `Bearer ${token}` },
});
expect(withoutConsent.statusCode).toBe(200);
const bodyWithout = JSON.parse(withoutConsent.body) as {
recommendations: Array<{ provenance?: unknown[]; whySv: string }>;
};
expect(bodyWithout.recommendations.length).toBeGreaterThan(0);
for (const r of bodyWithout.recommendations) {
expect(r.provenance ?? []).toHaveLength(0);
expect(r.whySv).not.toContain("berättat");
}
// Grant personalization consent.
await testDb.db
.insert(schema.userConsents)
.values({ userId, kind: "personalization", status: "granted" })
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: { status: "granted" },
});
const withConsent = await app.inject({
method: "GET",
url: "/v1/recommendations/what-to-eat?limit=5",
headers: { authorization: `Bearer ${token}` },
});
expect(withConsent.statusCode).toBe(200);
const bodyWith = JSON.parse(withConsent.body) as {
recommendations: Array<{ recipeId: string; provenance?: unknown[]; score: number }>;
};
expect(bodyWith.recommendations.length).toBeGreaterThan(0);
// Consent alone does not guarantee provenance; it just enables the path.
// We verify determinism: same call twice = same order.
const second = await app.inject({
method: "GET",
url: "/v1/recommendations/what-to-eat?limit=5",
headers: { authorization: `Bearer ${token}` },
});
const bodySecond = JSON.parse(second.body) as {
recommendations: Array<{ recipeId: string; score: number }>;
};
expect(bodyWith.recommendations.map((r) => r.recipeId)).toEqual(
bodySecond.recommendations.map((r) => r.recipeId),
);
});
it("quick-start auto-creates a household with default storage locations", async () => { it("quick-start auto-creates a household with default storage locations", async () => {
const registerRes = await app.inject({ const registerRes = await app.inject({
method: "POST", method: "POST",
+24 -5
View File
@@ -1,15 +1,24 @@
import type { RecommendationCandidate, RecommendationContext } from "./types.js"; import type {
ProvenanceEntry,
RecommendationCandidate,
RecommendationContext,
} from "./types.js";
import { renderProvenance } from "./provenance-templates.js";
/** /**
* "Varför rekommenderar vi detta?" (spec §18). * "Varför rekommenderar vi detta?" (spec §18, S1).
* Bygger en ärlig, konkret svensk förklaring ur de deterministiska delpoängen. * Bygger en ärlig, konkret svensk förklaring ur:
* Exempel ur specen: "Ni har 92 % av ingredienserna. Kycklingen bör användas * 1. Grundade delpoäng (täckning, utgångsdatum, etc.).
* senast i morgon. Rätten ger 58 gram protein per portion …" * 2. Personaliserings-mallar från provenans (endast om samtycke granted).
*
* Alla användarvisningstexter kommer från mallar, inte fri AI-text.
* R7: näringsrelaterad copy är positiv/stödjande, aldrig restriktiv.
*/ */
export function buildWhySv( export function buildWhySv(
candidate: RecommendationCandidate, candidate: RecommendationCandidate,
ctx: RecommendationContext, ctx: RecommendationContext,
parts: Record<string, number>, parts: Record<string, number>,
provenance: ProvenanceEntry[] = [],
): string { ): string {
const sentences: string[] = []; const sentences: string[] = [];
@@ -31,6 +40,7 @@ export function buildWhySv(
sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`); sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`);
} }
// R7: positiv näringsframing
const protein = Math.round(candidate.nutritionPerPortion.proteinG); const protein = Math.round(candidate.nutritionPerPortion.proteinG);
if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) { if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) {
sentences.push(`Rätten ger ${protein} gram protein per portion.`); sentences.push(`Rätten ger ${protein} gram protein per portion.`);
@@ -63,6 +73,15 @@ export function buildWhySv(
sentences.push("Matchar det du är sugen på."); sentences.push("Matchar det du är sugen på.");
} }
// S1: personaliserings-mallar från provenans
if (ctx.personalizationEnabled && provenance.length > 0) {
const lang = "sv-SE"; // explain.ts används för närvarande bara för svenska whySv
const rendered = renderProvenance(provenance.slice(0, 2), lang); // max 2 personliga satser
if (rendered) {
sentences.push(rendered);
}
}
if (sentences.length === 0) { if (sentences.length === 0) {
sentences.push("En balanserad rätt som passar er profil."); sentences.push("En balanserad rätt som passar er profil.");
} }
@@ -3,3 +3,4 @@ export * from "./scoring.js";
export * from "./explain.js"; export * from "./explain.js";
export * from "./craving.js"; export * from "./craving.js";
export * from "./season.js"; export * from "./season.js";
export * from "./provenance-templates.js";
@@ -0,0 +1,184 @@
import type { ProvenanceEntry } from "./types.js";
/**
* Mall-baserade provenienssträngar för personalisering (S1).
* Ingen fri AI-text till användaren — alla strängar byggs från grundade fakta.
* R7/stödjande ton: positiv framing, aldrig restriktiv eller skam.
*/
export type LanguageTag =
| "sv"
| "en"
| "es"
| "it"
| "de"
| "fr"
| "da"
| "nb"
| "fi"
| "nl"
| "pl"
| "pt";
const TEMPLATES: Record<
string,
Record<LanguageTag, string>
> = {
favoriteCuisine: {
sv: "För att du har berättat att du gillar {{cuisine}} mat.",
en: "Because you said you like {{cuisine}} food.",
es: "Porque dijiste que te gusta la comida {{cuisine}}.",
it: "Perché hai detto che ti piace la cucina {{cuisine}}.",
de: "Weil du gesagt hast, dass du {{cuisine}} Essen magst.",
fr: "Parce que vous avez dit que vous aimiez la cuisine {{cuisine}}.",
da: "Fordi du har sagt, at du kan lide {{cuisine}} mad.",
nb: "Fordi du har sagt at du liker {{cuisine}} mat.",
fi: "Koska kerroit pitäväsi {{cuisine}} ruoasta.",
nl: "Omdat je hebt gezegd dat je {{cuisine}} eten lekker vindt.",
pl: "Ponieważ powiedziałeś, że lubisz kuchnię {{cuisine}}.",
pt: "Porque disseste que gostas de comida {{cuisine}}.",
},
cookedOften: {
sv: "För att du lagat {{recipe}} {{count}} gånger den senaste månaden.",
en: "Because you've cooked {{recipe}} {{count}} times in the past month.",
es: "Porque has cocinado {{recipe}} {{count}} veces el último mes.",
it: "Perché hai cucinato {{recipe}} {{count}} volte nell'ultimo mese.",
de: "Weil du {{recipe}} {{count}} Mal im letzten Monat gekocht hast.",
fr: "Parce que vous avez cuisiné {{recipe}} {{count}} fois le mois dernier.",
da: "Fordi du har lavet {{recipe}} {{count}} gange den seneste måned.",
nb: "Fordi du har laget {{recipe}} {{count}} ganger den siste måneden.",
fi: "Koska olet kokannut {{recipe}} {{count}} kertaa viimeisen kuukauden aikana.",
nl: "Omdat je {{recipe}} {{count}} keer hebt gekookt afgelopen maand.",
pl: "Ponieważ gotowałeś {{recipe}} {{count}} razy w ostatnim miesiącu.",
pt: "Porque cozinhaste {{recipe}} {{count}} vezes no último mês.",
},
expiringIngredient: {
sv: "För att ni har {{count}} {{ingredient}} som bör användas inom {{days}} dagar.",
en: "Because you have {{count}} {{ingredient}} to use within {{days}} days.",
es: "Porque tenéis {{count}} {{ingredient}} para usar en {{days}} días.",
it: "Perché avete {{count}} {{ingredient}} da usare entro {{days}} giorni.",
de: "Weil ihr {{count}} {{ingredient}} habt, die binnen {{days}} Tagen verwendet werden sollten.",
fr: "Parce que vous avez {{count}} {{ingredient}} à utiliser dans {{days}} jours.",
da: "Fordi I har {{count}} {{ingredient}}, der bør bruges inden for {{days}} dage.",
nb: "Fordi dere har {{count}} {{ingredient}} som bør brukes innen {{days}} dager.",
fi: "Koska sinulla on {{count}} {{ingredient}}, jotka tulisi käyttää {{days}} päivän kuluessa.",
nl: "Omdat jullie {{count}} {{ingredient}} hebben die binnen {{days}} dagen gebruikt moeten worden.",
pl: "Ponieważ macie {{count}} {{ingredient}}, które należy zużyć w ciągu {{days}} dni.",
pt: "Porque tens {{count}} {{ingredient}} para usar em {{days}} dias.",
},
fitsProteinGoal: {
sv: "För att det passar ditt proteinmål i dag.",
en: "Because it fits your protein goal for today.",
es: "Porque encaja con tu objetivo de proteína de hoy.",
it: "Perché si adatta al tuo obiettivo proteico di oggi.",
de: "Weil es zu deinem Protein-Ziel für heute passt.",
fr: "Parce que cela correspond à votre objectif de protéines pour aujourd'hui.",
da: "Fordi det passer til dit proteinmål i dag.",
nb: "Fordi det passer proteinmålet ditt i dag.",
fi: "Koska se sopii päivän proteiinitavoitteesi.",
nl: "Omdat het past bij je eiwitdoel voor vandaag.",
pl: "Ponieważ pasuje do dzisiejszego celu białkowego.",
pt: "Porque se adequa à tua meta de proteína para hoje.",
},
fitsVegetableGoal: {
sv: "För att det bidrar till dina grönsaker i dag.",
en: "Because it contributes to your vegetables for today.",
es: "Porque contribuye a tus verduras de hoy.",
it: "Perché contribuisce alle tue verdure di oggi.",
de: "Weil es zu deinem Gemüse für heute beiträgt.",
fr: "Parce que cela contribue à vos légumes d'aujourd'hui.",
da: "Fordi det bidrager til dine grøntsager i dag.",
nb: "Fordi det bidrar til grønnsakene dine i dag.",
fi: "Koska se lisää päivän kasvissi saantia.",
nl: "Omdat het bijdraagt aan je groenten voor vandaag.",
pl: "Ponieważ przyczynia się do dzisiejszego spożycia warzyw.",
pt: "Porque contribui para os teus vegetais de hoje.",
},
tastePreference: {
sv: "För att det matchar din smakprofil ({{axis}}).",
en: "Because it matches your taste profile ({{axis}}).",
es: "Porque coincide con tu perfil de sabor ({{axis}}).",
it: "Perché corrisponde al tuo profilo di gusto ({{axis}}).",
de: "Weil es zu deinem Geschmacksprofil passt ({{axis}}).",
fr: "Parce que cela correspond à votre profil de goût ({{axis}}).",
da: "Fordi det matcher din smagsprofil ({{axis}}).",
nb: "Fordi det matcher smaksprofilen din ({{axis}}).",
fi: "Koska se sopii makuprofiilisi ({{axis}}).",
nl: "Omdat het bij je smaakprofiel past ({{axis}}).",
pl: "Ponieważ pasuje do Twojego profilu smakowego ({{axis}}).",
pt: "Porque corresponde ao teu perfil de sabor ({{axis}}).",
},
usesStapleYouFinish: {
sv: "För att ni brukar äta upp {{ingredient}} när ni lagar det.",
en: "Because you usually finish {{ingredient}} when you cook it.",
es: "Porque soléis acabar {{ingredient}} cuando lo cocináis.",
it: "Perché di solito finite {{ingredient}} quando lo cucinate.",
de: "Weil ihr {{ingredient}} normalerweise aufesset, wenn ihr es kocht.",
fr: "Parce que vous finissez habituellement {{ingredient}} quand vous le cuisinez.",
da: "Fordi I plejer at spise {{ingredient}} op, når I laver det.",
nb: "Fordi dere pleier å spise opp {{ingredient}} når dere lager det.",
fi: "Koska yleensä syöt {{ingredient}} loppuun, kun kokkaat sitä.",
nl: "Omdat jullie {{ingredient}} meestal opeten als jullie het koken.",
pl: "Ponieważ zwykle zjadacie {{ingredient}}, gdy to gotujecie.",
pt: "Porque normalmente acabas {{ingredient}} quando cozinhas.",
},
};
export function renderProvenance(
entries: ProvenanceEntry[],
languageTag: string,
): string {
const lang = (languageTag.split("-")[0] ?? "sv") as LanguageTag;
const parts: string[] = [];
for (const entry of entries) {
const tmpl = TEMPLATES[entry.key];
if (!tmpl) continue;
let s = tmpl[lang] ?? tmpl.sv;
for (const [k, v] of Object.entries(entry.args)) {
s = s.replaceAll(`{{${k}}}`, String(v));
}
parts.push(s);
}
return parts.join(" ");
}
export function renderProvenanceList(
entries: ProvenanceEntry[],
languageTag: string,
): string[] {
const lang = (languageTag.split("-")[0] ?? "sv") as LanguageTag;
return entries
.map((entry) => {
const tmpl = TEMPLATES[entry.key];
if (!tmpl) return null;
let s = tmpl[lang] ?? tmpl.sv;
for (const [k, v] of Object.entries(entry.args)) {
s = s.replaceAll(`{{${k}}}`, String(v));
}
return s;
})
.filter((s): s is string => s != null);
}
/** Förbjudna ord/fraser i proveniens/copy — R3 (ingen skam) + R7 (välmående). */
export const FORBIDDEN_COPY_PATTERNS = [
/överskrid/i,
/bara .* kvar/i,
/begränsa/i,
/undvik/i,
/skärp/i,
/skäms/i,
/dålig/i,
/för mycket/i,
/för lite/i,
/borde inte/i,
/får inte/i,
/måste sluta/i,
/överdriv/i,
/kalori/i, // näringsmål ska ramas positivt, aldrig kcal-centrerat
];
/** Kontrollera att en sträng inte innehåller förbjuden copy. */
export function containsForbiddenCopy(text: string): boolean {
return FORBIDDEN_COPY_PATTERNS.some((re) => re.test(text));
}
+178 -1
View File
@@ -1,4 +1,7 @@
import type { MemoryItem, TasteSignal } from "@app/shared-types";
import type { import type {
CookingAssumption,
ProvenanceEntry,
RecommendationCandidate, RecommendationCandidate,
RecommendationContext, RecommendationContext,
ScoredRecommendation, ScoredRecommendation,
@@ -17,6 +20,7 @@ export function scoreCandidate(
weights: ScoringWeights = DEFAULT_WEIGHTS, weights: ScoringWeights = DEFAULT_WEIGHTS,
): ScoredRecommendation { ): ScoredRecommendation {
const parts: Record<string, number> = {}; const parts: Record<string, number> = {};
const provenance: ProvenanceEntry[] = [];
// 1. Ingredienstäckning kärnan i "utgå från vad som finns hemma". // 1. Ingredienstäckning kärnan i "utgå från vad som finns hemma".
parts.coverage = candidate.coverage.coverage; parts.coverage = candidate.coverage.coverage;
@@ -85,6 +89,25 @@ export function scoreCandidate(
// 12. "Jag är sugen på" (spec §19). // 12. "Jag är sugen på" (spec §19).
parts.craving = cravingFit(candidate, ctx); parts.craving = cravingFit(candidate, ctx);
// 1315. S1 personalisering — endast om samtycke granted.
if (ctx.personalizationEnabled) {
const memoryResult = memoryFit(candidate, ctx);
parts.memoryFit = memoryResult.score;
provenance.push(...memoryResult.provenance);
const tasteResult = tasteFit(candidate, ctx);
parts.tasteFit = tasteResult.score;
provenance.push(...tasteResult.provenance);
const assumptionResult = cookingAssumptionFit(candidate, ctx);
parts.cookingAssumptionFit = assumptionResult.score;
provenance.push(...assumptionResult.provenance);
} else {
parts.memoryFit = 0;
parts.tasteFit = 0;
parts.cookingAssumptionFit = 0;
}
const score = weightedSum(parts, weights); const score = weightedSum(parts, weights);
return { return {
@@ -92,7 +115,7 @@ export function scoreCandidate(
titleSv: candidate.titleSv, titleSv: candidate.titleSv,
score: Math.round(score * 10) / 10, score: Math.round(score * 10) / 10,
parts, parts,
whySv: buildWhySv(candidate, ctx, parts), whySv: buildWhySv(candidate, ctx, parts, provenance),
missingIngredients: candidate.coverage.missing missingIngredients: candidate.coverage.missing
.filter((m) => !m.optional) .filter((m) => !m.optional)
.map((m) => m.displayNameSv), .map((m) => m.displayNameSv),
@@ -101,6 +124,7 @@ export function scoreCandidate(
daysLeft: m.mostUrgentDaysLeft, daysLeft: m.mostUrgentDaysLeft,
})), })),
coveragePercent: Math.round(candidate.coverage.coverage * 100), coveragePercent: Math.round(candidate.coverage.coverage * 100),
provenance,
}; };
} }
@@ -186,6 +210,158 @@ function cravingFit(candidate: RecommendationCandidate, ctx: RecommendationConte
return checks === 0 ? 0.5 : hits / checks; return checks === 0 ? 0.5 : hits / checks;
} }
interface FitResult {
score: number;
provenance: ProvenanceEntry[];
}
function memoryFit(candidate: RecommendationCandidate, ctx: RecommendationContext): FitResult {
const memories = ctx.memoryItems ?? [];
if (memories.length === 0 || !ctx.personalizationEnabled) {
return { score: 0, provenance: [] };
}
let score = 0;
const provenance: ProvenanceEntry[] = [];
for (const memory of memories) {
if (memory.paused) continue;
const value = (memory.value ?? {}) as Record<string, unknown>;
// Favoritkök
if (memory.kind === "structured_fact" && value.favoriteCuisine === candidate.cuisine) {
const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : memory.origin === "observed" ? 0.7 : 0.4;
score = Math.max(score, weight);
if (weight >= 0.7) {
provenance.push({
key: "favoriteCuisine",
args: { cuisine: String(value.favoriteCuisine) },
});
}
}
// Gillade rätter / receptminne
if (memory.kind === "recipe_memory" && value.recipeId === candidate.recipeId) {
const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : 0.6;
score = Math.max(score, weight);
}
// Gillade ingredienser
if (
memory.kind === "structured_fact" &&
typeof value.likedIngredientId === "string" &&
candidate.ingredientIds?.includes(value.likedIngredientId)
) {
const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 0.9 : 0.5;
score = Math.max(score, weight);
}
}
// Matlagningsfrekvens (observed events, ej AI-gissning)
if (candidate.daysSinceLastCooked != null && candidate.daysSinceLastCooked <= 30) {
// Ingen boost för nyligen lagat (variety straffar redan), men vi noterar mönster.
}
return { score: clamp01(score), provenance };
}
function tasteFit(candidate: RecommendationCandidate, ctx: RecommendationContext): FitResult {
const signals = ctx.tasteSignals ?? [];
if (signals.length === 0 || !ctx.personalizationEnabled) {
return { score: 0, provenance: [] };
}
let total = 0;
let count = 0;
const provenance: ProvenanceEntry[] = [];
// Mappa recept till axlar via tags/cuisine/ingredienser (förenklad heuristik).
const recipeAxes = detectRecipeAxes(candidate);
for (const signal of signals) {
if (!recipeAxes.includes(signal.axis)) continue;
const contribution = signal.direction * signal.strength;
total += contribution;
count += 1;
if (Math.abs(contribution) >= 0.5) {
provenance.push({
key: "tastePreference",
args: { axis: signal.axis },
});
}
}
if (count === 0) return { score: 0, provenance: [] };
const raw = total / count; // -1 … +1
const score = clamp01((raw + 1) / 2); // 0 … 1
return { score, provenance };
}
function detectRecipeAxes(candidate: RecommendationCandidate): string[] {
const axes: string[] = [];
const title = candidate.titleSv.toLowerCase();
const tags = new Set(candidate.tags.map((t) => t.toLowerCase()));
if (candidate.spiceLevel >= 3 || tags.has("spicy")) axes.push("spice");
if (tags.has("sött") || tags.has("dessert") || title.includes("socker")) axes.push("sweetness");
if (tags.has("syrligt") || title.includes("citron") || title.includes("lime")) axes.push("acid");
if (
title.includes("krämig") ||
title.includes("grädd") ||
tags.has("creamy") ||
tags.has("krämig")
)
axes.push("creaminess");
if (title.includes("vitlök") || tags.has("garlic")) axes.push("garlic");
if (tags.has("herby") || title.includes("dill") || title.includes("basilika")) axes.push("herbs");
if (tags.has("umami") || title.includes("soja") || title.includes("svamp")) axes.push("umami");
return axes;
}
function cookingAssumptionFit(
candidate: RecommendationCandidate,
ctx: RecommendationContext,
): FitResult {
const assumptions = ctx.cookingAssumptions ?? [];
if (assumptions.length === 0 || !ctx.personalizationEnabled) {
return { score: 0, provenance: [] };
}
const ids = candidate.ingredientIds ?? [];
let total = 0;
let matched = 0;
let bestIngredient: string | null = null;
let bestScore = -1;
for (const assumption of assumptions) {
if (!ids.includes(assumption.canonicalIngredientId)) continue;
const eaten = assumption.averageEatenPortions ?? 0;
const leftovers = assumption.averageLeftoverPortions ?? 0;
const observed = assumption.observationCount;
if (observed < 2) continue;
const finishRate = eaten > 0 ? eaten / (eaten + leftovers) : 0;
const score = clamp01(finishRate * Math.min(1, observed / 5));
total += score;
matched += 1;
if (score > bestScore) {
bestScore = score;
bestIngredient = assumption.canonicalIngredientId;
}
}
if (matched === 0) return { score: 0, provenance: [] };
const provenance: ProvenanceEntry[] = [];
if (bestIngredient && bestScore >= 0.7) {
provenance.push({ key: "usesStapleYouFinish", args: { ingredient: bestIngredient } });
}
return { score: clamp01(total / matched), provenance };
}
function weightedSum(parts: Record<string, number>, weights: ScoringWeights): number { function weightedSum(parts: Record<string, number>, weights: ScoringWeights): number {
let total = 0; let total = 0;
for (const [key, value] of Object.entries(parts)) { for (const [key, value] of Object.entries(parts)) {
@@ -198,6 +374,7 @@ function weightedSum(parts: Record<string, number>, weights: ScoringWeights): nu
function clamp01(v: number): number { function clamp01(v: number): number {
return Math.max(0, Math.min(1, v)); return Math.max(0, Math.min(1, v));
} }
function clampPart(v: number): number { function clampPart(v: number): number {
return Math.max(-1, Math.min(1, v)); return Math.max(-1, Math.min(1, v));
} }
@@ -1,13 +1,29 @@
import type { import type {
Cuisine, Cuisine,
MealType, MealType,
MemoryItem,
NutritionValues, NutritionValues,
RecipeTag, RecipeTag,
Season, Season,
TasteSignal,
WeatherHint, WeatherHint,
} from "@app/shared-types"; } from "@app/shared-types";
import type { CoverageResult } from "@app/recipe-engine"; import type { CoverageResult } from "@app/recipe-engine";
/** Hushållsspecifikt förbrukningsantagande per ingrediens. */
export interface CookingAssumption {
canonicalIngredientId: string;
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
}
/** Provenienspost: varför valdes detta förslag? */
export interface ProvenanceEntry {
key: string;
args: Record<string, string | number>;
}
/** Kandidat som poängsätts. Säkerhetsfiltrering har redan skett (blockers borta). */ /** Kandidat som poängsätts. Säkerhetsfiltrering har redan skett (blockers borta). */
export interface RecommendationCandidate { export interface RecommendationCandidate {
recipeId: string; recipeId: string;
@@ -27,6 +43,8 @@ export interface RecommendationCandidate {
daysSinceLastCooked?: number | null; daysSinceLastCooked?: number | null;
/** Hushållets snittbetyg på receptet, om finns. */ /** Hushållets snittbetyg på receptet, om finns. */
householdRating?: number | null; householdRating?: number | null;
/** Kanoniska ingrediens-id:n för receptet (inklusive valfria) för tasteFit/cookingAssumptionFit. */
ingredientIds?: string[];
} }
export interface RecommendationContext { export interface RecommendationContext {
@@ -48,6 +66,14 @@ export interface RecommendationContext {
cravingTags?: string[] | undefined; cravingTags?: string[] | undefined;
cravingCuisine?: Cuisine | undefined; cravingCuisine?: Cuisine | undefined;
cravingMaxKcal?: number | undefined; cravingMaxKcal?: number | undefined;
/** Personligt minne; bara inläst om personalization-samtycke granted. */
memoryItems?: MemoryItem[];
/** Smaksignaler; bara inläst om personalization-samtycke granted. */
tasteSignals?: TasteSignal[];
/** Förbrukningsantaganden; bara inläst om personalization-samtycke granted. */
cookingAssumptions?: CookingAssumption[];
/** Hård grind: false = ignorera alla personliga signaler. */
personalizationEnabled: boolean;
} }
export interface ScoredRecommendation { export interface ScoredRecommendation {
@@ -60,6 +86,8 @@ export interface ScoredRecommendation {
missingIngredients: string[]; missingIngredients: string[];
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>; usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
coveragePercent: number; coveragePercent: number;
/** Proveniensposter som `whySv` byggs från. */
provenance: ProvenanceEntry[];
} }
/** Vikter justerbara via feature flags/admin utan koddeploy. */ /** Vikter justerbara via feature flags/admin utan koddeploy. */
@@ -76,6 +104,10 @@ export interface ScoringWeights {
variety: number; variety: number;
weather: number; weather: number;
craving: number; craving: number;
/** Nya S1-vikter; 0 om personalisering är av. */
memoryFit: number;
tasteFit: number;
cookingAssumptionFit: number;
} }
export const DEFAULT_WEIGHTS: ScoringWeights = { export const DEFAULT_WEIGHTS: ScoringWeights = {
@@ -91,4 +123,15 @@ export const DEFAULT_WEIGHTS: ScoringWeights = {
variety: 6, variety: 6,
weather: 3, weather: 3,
craving: 15, craving: 15,
memoryFit: 6,
tasteFit: 6,
cookingAssumptionFit: 4,
};
/** Vikter när personalisering är avslagen exakt samma som tidigare. */
export const NON_PERSONALIZED_WEIGHTS: ScoringWeights = {
...DEFAULT_WEIGHTS,
memoryFit: 0,
tasteFit: 0,
cookingAssumptionFit: 0,
}; };
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { CoverageResult } from "@app/recipe-engine"; import type { CoverageResult } from "@app/recipe-engine";
import type { MemoryItem, TasteSignal } from "@app/shared-types";
import { import {
containsForbiddenCopy,
DEFAULT_WEIGHTS,
easterSunday, easterSunday,
isEventActive, isEventActive,
midsummerEve, midsummerEve,
NON_PERSONALIZED_WEIGHTS,
parseCraving, parseCraving,
rankAll, rankAll,
renderProvenance,
scoreCandidate, scoreCandidate,
seasonForDate, seasonForDate,
type CookingAssumption,
type RecommendationCandidate, type RecommendationCandidate,
type RecommendationContext, type RecommendationContext,
} from "../src/index.js"; } from "../src/index.js";
@@ -52,6 +58,7 @@ const ctx: RecommendationContext = {
favoriteCuisines: ["swedish"], favoriteCuisines: ["swedish"],
remainingProteinG: 60, remainingProteinG: 60,
remainingKcal: 800, remainingKcal: 800,
personalizationEnabled: false,
}; };
describe("poängsättning (spec §18)", () => { describe("poängsättning (spec §18)", () => {
@@ -157,3 +164,168 @@ describe("säsongs- och eventmotor (spec §28)", () => {
expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false); expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false);
}); });
}); });
describe("S1 personalisering", () => {
const memoryBase: MemoryItem = {
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: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const aiMemory: MemoryItem = {
...memoryBase,
id: "m2",
origin: "ai_inferred",
confidence: 0.5,
verifiedByUser: false,
value: { favoriteCuisine: "swedish" },
};
const tastePositive: TasteSignal = {
id: "t1",
userId: "u1",
axis: "spice",
direction: 1,
strength: 0.8,
origin: "user_stated",
createdAt: new Date().toISOString(),
};
const tasteNegative: TasteSignal = {
...tastePositive,
id: "t2",
direction: -1,
};
const cookingAssumption: CookingAssumption = {
canonicalIngredientId: "chicken",
averageEatenPortions: 4,
averageLeftoverPortions: 0.5,
observationCount: 5,
};
it("memoryFit boostar verified user_stated högre än ai_inferred", () => {
const verifiedCtx: RecommendationContext = {
...ctx,
personalizationEnabled: true,
memoryItems: [memoryBase],
tasteSignals: [],
cookingAssumptions: [],
};
const aiCtx: RecommendationContext = {
...ctx,
personalizationEnabled: true,
memoryItems: [aiMemory],
tasteSignals: [],
cookingAssumptions: [],
};
const verified = scoreCandidate(candidate({ cuisine: "swedish" }), verifiedCtx);
const ai = scoreCandidate(candidate({ cuisine: "swedish" }), aiCtx);
expect(verified.parts.memoryFit ?? 0).toBeGreaterThan(ai.parts.memoryFit ?? 0);
});
it("tasteFit ger positiv riktning för positiv signal", () => {
const spicyCandidate = candidate({ spiceLevel: 3 });
const positiveCtx: RecommendationContext = {
...ctx,
personalizationEnabled: true,
memoryItems: [],
tasteSignals: [tastePositive],
cookingAssumptions: [],
};
const negativeCtx: RecommendationContext = {
...ctx,
personalizationEnabled: true,
memoryItems: [],
tasteSignals: [tasteNegative],
cookingAssumptions: [],
};
const positive = scoreCandidate(spicyCandidate, positiveCtx);
const negative = scoreCandidate(spicyCandidate, negativeCtx);
expect(positive.parts.tasteFit ?? 0).toBeGreaterThan(negative.parts.tasteFit ?? 0);
});
it("cookingAssumptionFit boostar ingrediens som hushållet brukar äta upp", () => {
const ctxWithAssumption: RecommendationContext = {
...ctx,
personalizationEnabled: true,
memoryItems: [],
tasteSignals: [],
cookingAssumptions: [cookingAssumption],
};
const boosted = scoreCandidate(
candidate({ ingredientIds: ["chicken", "onion"] }),
ctxWithAssumption,
);
const noMatch = scoreCandidate(
candidate({ ingredientIds: ["beef", "onion"] }),
ctxWithAssumption,
);
expect(boosted.parts.cookingAssumptionFit ?? 0).toBeGreaterThan(
noMatch.parts.cookingAssumptionFit ?? 0,
);
});
it("utan personalizationEnabled är personliga delpoäng noll", () => {
const noPersonalizationCtx: RecommendationContext = {
...ctx,
personalizationEnabled: false,
memoryItems: [memoryBase],
tasteSignals: [tastePositive],
cookingAssumptions: [cookingAssumption],
};
const scored = scoreCandidate(
candidate({ cuisine: "swedish", spiceLevel: 3 }),
noPersonalizationCtx,
);
expect(scored.parts.memoryFit).toBe(0);
expect(scored.parts.tasteFit).toBe(0);
expect(scored.parts.cookingAssumptionFit).toBe(0);
});
it("proveniens renderas från mall med grundade fakta", () => {
const rendered = renderProvenance(
[{ key: "favoriteCuisine", args: { cuisine: "svensk" } }],
"sv-SE",
);
expect(rendered).toContain("svensk");
expect(rendered).toContain("berättat");
});
it("förbjuden copy upptäcks", () => {
expect(containsForbiddenCopy("Du har överskridit ditt kalorimål")).toBe(true);
expect(containsForbiddenCopy("Passar ditt proteinmål")).toBe(false);
});
it("scoring är deterministisk", () => {
const personalCtx: RecommendationContext = {
...ctx,
personalizationEnabled: true,
memoryItems: [memoryBase],
tasteSignals: [tastePositive],
cookingAssumptions: [cookingAssumption],
};
const c = candidate({ ingredientIds: ["chicken"], spiceLevel: 3 });
const a = scoreCandidate(c, personalCtx);
const b = scoreCandidate(c, personalCtx);
expect(a.score).toBe(b.score);
expect(a.whySv).toBe(b.whySv);
});
it("icke-personaliserade vikter har noll för S1-komponenter", () => {
expect(NON_PERSONALIZED_WEIGHTS.memoryFit).toBe(0);
expect(NON_PERSONALIZED_WEIGHTS.tasteFit).toBe(0);
expect(NON_PERSONALIZED_WEIGHTS.cookingAssumptionFit).toBe(0);
// Befintliga vikter ska vara oförändrade.
expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage);
});
});