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