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:
@@ -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);
|
||||
|
||||
// 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<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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user