feat(recommendation-engine): S1 personalisering för 'Vad ska vi äta?'

- Mallbaserad proveniens i 12 språk (inga fria AI-texter i rekommendationer).
- memoryFit, tasteFit, cookingAssumptionFit endast vid personalization-samtycke.
- Hård grind i API:et: läser memory_items/taste_signals/cooking_assumption_profiles
  endast när userConsents.personalization = granted.
- NON_PERSONALIZED_WEIGHTS bevarar existerande beteende vid avsaknad av samtycke.
- Positiv, icke-restriktiv näringscopy (R7).
- Deterministisk scoring + enhetstester för S1.
- Integrationstest som verifierar provenans-gate med/utan samtycke.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 03:23:31 +07:00
parent 16a7849e71
commit 0885f5bceb
8 changed files with 767 additions and 9 deletions
+24 -5
View File
@@ -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.");
}