From 0885f5bcebeab8ebfe1b7cdbf1ea5399a641a3da Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Mon, 10 Aug 2026 03:23:31 +0700 Subject: [PATCH] =?UTF-8?q?feat(recommendation-engine):=20S1=20personalise?= =?UTF-8?q?ring=20f=C3=B6r=20'Vad=20ska=20vi=20=C3=A4ta=3F'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/api/src/routes/recommendations.ts | 102 +++++++++- apps/api/test/recommendations.test.ts | 66 +++++++ packages/recommendation-engine/src/explain.ts | 29 ++- packages/recommendation-engine/src/index.ts | 1 + .../src/provenance-templates.ts | 184 ++++++++++++++++++ packages/recommendation-engine/src/scoring.ts | 179 ++++++++++++++++- packages/recommendation-engine/src/types.ts | 43 ++++ .../recommendation-engine/test/engine.test.ts | 172 ++++++++++++++++ 8 files changed, 767 insertions(+), 9 deletions(-) create mode 100644 packages/recommendation-engine/src/provenance-templates.ts diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index 82acb6e..18fa260 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -1,6 +1,7 @@ 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 type { MemoryItem, TasteSignal } from "@app/shared-types"; import { whatToEatQuerySchema } from "@app/validation"; import { computeCoverage, @@ -11,10 +12,12 @@ import { } from "@app/recipe-engine"; import { isEventActive, + NON_PERSONALIZED_WEIGHTS, parseCraving, rankAll, seasonForDate, summarizeContext, + type CookingAssumption, type RecommendationCandidate, type RecommendationContext, } from "@app/recommendation-engine"; @@ -216,7 +219,94 @@ export async function recommendationRoutes(app: FastifyInstance) { .groupBy(schema.recipeRatings.recipeId); 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 ctx: RecommendationContext = { @@ -233,6 +323,10 @@ export async function recommendationRoutes(app: FastifyInstance) { cravingTags: craving?.tags, cravingCuisine: craving?.cuisine, 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 --- @@ -287,10 +381,12 @@ export async function recommendationRoutes(app: FastifyInstance) { ? 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), }); } - 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) --- if (await app.flags.isEnabled("ai_rerank", req.userId)) { diff --git a/apps/api/test/recommendations.test.ts b/apps/api/test/recommendations.test.ts index 5eb5e0c..af55d16 100644 --- a/apps/api/test/recommendations.test.ts +++ b/apps/api/test/recommendations.test.ts @@ -113,6 +113,72 @@ describe("what-to-eat without household", () => { 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 () => { const registerRes = await app.inject({ method: "POST", diff --git a/packages/recommendation-engine/src/explain.ts b/packages/recommendation-engine/src/explain.ts index b23c989..b70eb60 100644 --- a/packages/recommendation-engine/src/explain.ts +++ b/packages/recommendation-engine/src/explain.ts @@ -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). - * Bygger en ärlig, konkret svensk förklaring ur de deterministiska delpoängen. - * Exempel ur specen: "Ni har 92 % av ingredienserna. Kycklingen bör användas - * senast i morgon. Rätten ger 58 gram protein per portion …" + * "Varför rekommenderar vi detta?" (spec §18, S1). + * Bygger en ärlig, konkret svensk förklaring ur: + * 1. Grundade delpoäng (täckning, utgångsdatum, etc.). + * 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( candidate: RecommendationCandidate, ctx: RecommendationContext, parts: Record, + provenance: ProvenanceEntry[] = [], ): string { const sentences: string[] = []; @@ -31,6 +40,7 @@ export function buildWhySv( sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`); } + // R7: positiv näringsframing const protein = Math.round(candidate.nutritionPerPortion.proteinG); if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) { 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å."); } + // 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) { sentences.push("En balanserad rätt som passar er profil."); } diff --git a/packages/recommendation-engine/src/index.ts b/packages/recommendation-engine/src/index.ts index fe0fb8f..8be741c 100644 --- a/packages/recommendation-engine/src/index.ts +++ b/packages/recommendation-engine/src/index.ts @@ -3,3 +3,4 @@ export * from "./scoring.js"; export * from "./explain.js"; export * from "./craving.js"; export * from "./season.js"; +export * from "./provenance-templates.js"; diff --git a/packages/recommendation-engine/src/provenance-templates.ts b/packages/recommendation-engine/src/provenance-templates.ts new file mode 100644 index 0000000..184b2b0 --- /dev/null +++ b/packages/recommendation-engine/src/provenance-templates.ts @@ -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 +> = { + 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)); +} diff --git a/packages/recommendation-engine/src/scoring.ts b/packages/recommendation-engine/src/scoring.ts index bc45359..058c6cb 100644 --- a/packages/recommendation-engine/src/scoring.ts +++ b/packages/recommendation-engine/src/scoring.ts @@ -1,4 +1,7 @@ +import type { MemoryItem, TasteSignal } from "@app/shared-types"; import type { + CookingAssumption, + ProvenanceEntry, RecommendationCandidate, RecommendationContext, ScoredRecommendation, @@ -17,6 +20,7 @@ export function scoreCandidate( weights: ScoringWeights = DEFAULT_WEIGHTS, ): ScoredRecommendation { const parts: Record = {}; + const provenance: ProvenanceEntry[] = []; // 1. Ingredienstäckning – kärnan i "utgå från vad som finns hemma". parts.coverage = candidate.coverage.coverage; @@ -85,6 +89,25 @@ export function scoreCandidate( // 12. "Jag är sugen på" (spec §19). parts.craving = cravingFit(candidate, ctx); + // 13–15. 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); return { @@ -92,7 +115,7 @@ export function scoreCandidate( titleSv: candidate.titleSv, score: Math.round(score * 10) / 10, parts, - whySv: buildWhySv(candidate, ctx, parts), + whySv: buildWhySv(candidate, ctx, parts, provenance), missingIngredients: candidate.coverage.missing .filter((m) => !m.optional) .map((m) => m.displayNameSv), @@ -101,6 +124,7 @@ export function scoreCandidate( daysLeft: m.mostUrgentDaysLeft, })), 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; } +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; + + // 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, weights: ScoringWeights): number { let total = 0; for (const [key, value] of Object.entries(parts)) { @@ -198,6 +374,7 @@ function weightedSum(parts: Record, weights: ScoringWeights): nu function clamp01(v: number): number { return Math.max(0, Math.min(1, v)); } + function clampPart(v: number): number { return Math.max(-1, Math.min(1, v)); } diff --git a/packages/recommendation-engine/src/types.ts b/packages/recommendation-engine/src/types.ts index b199ec4..64e2a12 100644 --- a/packages/recommendation-engine/src/types.ts +++ b/packages/recommendation-engine/src/types.ts @@ -1,13 +1,29 @@ import type { Cuisine, MealType, + MemoryItem, NutritionValues, RecipeTag, Season, + TasteSignal, WeatherHint, } from "@app/shared-types"; 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; +} + /** Kandidat som poängsätts. Säkerhetsfiltrering har redan skett (blockers borta). */ export interface RecommendationCandidate { recipeId: string; @@ -27,6 +43,8 @@ export interface RecommendationCandidate { daysSinceLastCooked?: number | null; /** Hushållets snittbetyg på receptet, om finns. */ householdRating?: number | null; + /** Kanoniska ingrediens-id:n för receptet (inklusive valfria) – för tasteFit/cookingAssumptionFit. */ + ingredientIds?: string[]; } export interface RecommendationContext { @@ -48,6 +66,14 @@ export interface RecommendationContext { cravingTags?: string[] | undefined; cravingCuisine?: Cuisine | 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 { @@ -60,6 +86,8 @@ export interface ScoredRecommendation { missingIngredients: string[]; usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>; coveragePercent: number; + /** Proveniensposter som `whySv` byggs från. */ + provenance: ProvenanceEntry[]; } /** Vikter – justerbara via feature flags/admin utan koddeploy. */ @@ -76,6 +104,10 @@ export interface ScoringWeights { variety: number; weather: number; craving: number; + /** Nya S1-vikter; 0 om personalisering är av. */ + memoryFit: number; + tasteFit: number; + cookingAssumptionFit: number; } export const DEFAULT_WEIGHTS: ScoringWeights = { @@ -91,4 +123,15 @@ export const DEFAULT_WEIGHTS: ScoringWeights = { variety: 6, weather: 3, 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, }; diff --git a/packages/recommendation-engine/test/engine.test.ts b/packages/recommendation-engine/test/engine.test.ts index 1daf579..3f9b274 100644 --- a/packages/recommendation-engine/test/engine.test.ts +++ b/packages/recommendation-engine/test/engine.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from "vitest"; import type { CoverageResult } from "@app/recipe-engine"; +import type { MemoryItem, TasteSignal } from "@app/shared-types"; import { + containsForbiddenCopy, + DEFAULT_WEIGHTS, easterSunday, isEventActive, midsummerEve, + NON_PERSONALIZED_WEIGHTS, parseCraving, rankAll, + renderProvenance, scoreCandidate, seasonForDate, + type CookingAssumption, type RecommendationCandidate, type RecommendationContext, } from "../src/index.js"; @@ -52,6 +58,7 @@ const ctx: RecommendationContext = { favoriteCuisines: ["swedish"], remainingProteinG: 60, remainingKcal: 800, + personalizationEnabled: false, }; 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); }); }); + +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); + }); +});