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
+178 -1
View File
@@ -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<string, number> = {};
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);
// 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);
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<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 {
let total = 0;
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 {
return Math.max(0, Math.min(1, v));
}
function clampPart(v: number): number {
return Math.max(-1, Math.min(1, v));
}