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