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