From 4116653b51bef20b2b673ef7ae6bef641d64005c Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Tue, 11 Aug 2026 05:20:27 +0700 Subject: [PATCH] S6: i18n-paritet, provenans + eval, copy-skanning, fabricerade-minnes-eval --- apps/api/test/i18n-memory.test.ts | 86 +++ apps/worker/src/eval/cases.ts | 102 +++- apps/worker/src/eval/run.ts | 29 +- packages/memory-client/src/index.ts | 508 +++++++++++++++++- packages/memory-client/test/overview.test.ts | 86 ++- packages/recommendation-engine/src/explain.ts | 287 +++++++++- .../src/provenance-templates.ts | 9 + .../test/explanation-copy.test.ts | 188 +++++++ .../test/i18n-parity.test.ts | 104 ++++ 9 files changed, 1346 insertions(+), 53 deletions(-) create mode 100644 apps/api/test/i18n-memory.test.ts create mode 100644 packages/recommendation-engine/test/explanation-copy.test.ts create mode 100644 packages/recommendation-engine/test/i18n-parity.test.ts diff --git a/apps/api/test/i18n-memory.test.ts b/apps/api/test/i18n-memory.test.ts new file mode 100644 index 0000000..9a62977 --- /dev/null +++ b/apps/api/test/i18n-memory.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { renderMemorySummary, supportedMemorySummaryLanguages } from "@app/memory-client"; +import svCommon from "../../mobile/src/locales/sv/common.json" with { type: "json" }; +import enCommon from "../../mobile/src/locales/en/common.json" with { type: "json" }; +import esCommon from "../../mobile/src/locales/es/common.json" with { type: "json" }; +import itCommon from "../../mobile/src/locales/it/common.json" with { type: "json" }; +import deCommon from "../../mobile/src/locales/de/common.json" with { type: "json" }; +import frCommon from "../../mobile/src/locales/fr/common.json" with { type: "json" }; +import daCommon from "../../mobile/src/locales/da/common.json" with { type: "json" }; +import nbCommon from "../../mobile/src/locales/nb/common.json" with { type: "json" }; +import fiCommon from "../../mobile/src/locales/fi/common.json" with { type: "json" }; +import nlCommon from "../../mobile/src/locales/nl/common.json" with { type: "json" }; +import plCommon from "../../mobile/src/locales/pl/common.json" with { type: "json" }; +import ptCommon from "../../mobile/src/locales/pt/common.json" with { type: "json" }; + +const LOCALES: Record> = { + sv: svCommon, + en: enCommon, + es: esCommon, + it: itCommon, + de: deCommon, + fr: frCommon, + da: daCommon, + nb: nbCommon, + fi: fiCommon, + nl: nlCommon, + pl: plCommon, + pt: ptCommon, +}; + +const EXPECTED_LANGS = Object.keys(LOCALES); + +function getTransparency(catalog: Record): string | undefined { + const flat = catalog["onboarding.memoryTransparency"]; + if (typeof flat === "string") return flat; + const nested = catalog["onboarding"] as Record | undefined; + const nestedValue = nested?.["memoryTransparency"]; + return typeof nestedValue === "string" ? nestedValue : undefined; +} + +describe("S6 minnes-i18n + transparens-paritet", () => { + it("renderMemorySummary stödjer exakt 12 språk", () => { + const supported = supportedMemorySummaryLanguages(); + expect(supported.length).toBe(12); + expect(new Set(supported).size).toBe(12); + for (const lang of EXPECTED_LANGS) { + expect(supported).toContain(lang); + } + }); + + it("alla S5-value-former renderas på alla 12 språk", () => { + const cases = [ + { value: { favoriteCuisine: "italian" }, expectedSv: "Favoritkök: italian" }, + { value: { avoidIngredientId: "broccoli" }, expectedSv: "Undviker ingrediens: broccoli" }, + { value: { goal: "less_waste" }, expectedSv: "Mål: less_waste" }, + { value: { primaryGoal: "less_waste" }, expectedSv: "Primärt mål: less_waste" }, + { value: { allergen: "gluten" }, expectedSv: "Allergi: gluten" }, + { value: { spiceLevelMax: 2 }, expectedSv: "Max styrka: 2" }, + ]; + for (const { value, expectedSv } of cases) { + expect(renderMemorySummary({ summarySv: "", value }, "sv-SE")).toBe(expectedSv); + for (const lang of EXPECTED_LANGS) { + const rendered = renderMemorySummary({ summarySv: "", value }, `${lang}-XX`); + expect(rendered.length, `tom summary för ${lang}, ${JSON.stringify(value)}`).toBeGreaterThan(0); + expect(rendered).not.toBe(""); + } + } + }); + + it("transparens-strängen finns i alla 12 common.json", () => { + for (const [lang, catalog] of Object.entries(LOCALES)) { + const str = getTransparency(catalog); + expect(str, `saknas i ${lang}`).toBeDefined(); + expect(str!.length, `tom i ${lang}`).toBeGreaterThan(0); + } + }); + + it("inga dubblett-transparenssträngar över språk", () => { + const seen = new Set(); + for (const [lang, catalog] of Object.entries(LOCALES)) { + const str = getTransparency(catalog) ?? ""; + seen.add(str); + } + expect(seen.size).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/apps/worker/src/eval/cases.ts b/apps/worker/src/eval/cases.ts index eec9f22..135f569 100644 --- a/apps/worker/src/eval/cases.ts +++ b/apps/worker/src/eval/cases.ts @@ -1,4 +1,6 @@ import type { AamosTaskType, TaskInput, TaskOutput } from "@app/ai-contracts"; +import { buildWhy, containsForbiddenCopy } from "@app/recommendation-engine"; +import type { RecommendationCandidate, RecommendationContext } from "@app/recommendation-engine"; import { numbersPreserved } from "../processors/translation.js"; /** @@ -16,7 +18,7 @@ export interface EvalCheck { passed: boolean; } -export interface EvalCase { +export interface AamosEvalCase { id: string; taskType: T; descriptionSv: string; @@ -25,6 +27,19 @@ export interface EvalCase { verify(output: TaskOutput): EvalCheck[]; } +export interface DeterministicEvalCase { + id: string; + descriptionSv: string; + /** Hermetisk eval som inte anropar AAMOS (t.ex. copy-granskning). */ + run(): EvalCheck[]; +} + +export type EvalCase = AamosEvalCase | DeterministicEvalCase; + +export function isDeterministicEvalCase(c: EvalCase): c is DeterministicEvalCase { + return "run" in c; +} + const check = (name: string, passed: boolean): EvalCheck => ({ name, passed }); function confidenceInRange(values: number[]): boolean { @@ -190,4 +205,89 @@ export const EVAL_CASES: EvalCase[] = [ ]; }, }, + { + id: "memory-grounded-only", + taskType: "UPDATE_USER_MEMORY", + descriptionSv: "S1: AAMOS får inte fabricera minnen utan event-stöd", + input: { + scope: "user", + scopeId: "user-1", + events: [{ id: "evt-1", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }], + existingMemoryKeys: [], + }, + verify(output) { + const o = output as TaskOutput<"UPDATE_USER_MEMORY">; + const eventIds = new Set(["evt-1"]); + return [ + check("alla minnen har kända sourceEventIds", o.memoryUpdates.every((u) => u.sourceEventIds.every((id) => eventIds.has(id)))), + check("inga minnen utan sourceEventIds", o.memoryUpdates.every((u) => u.sourceEventIds.length > 0)), + check("origin är observed eller ai_inferred", o.memoryUpdates.every((u) => u.origin === "observed" || u.origin === "ai_inferred")), + ]; + }, + }, + { + id: "memory-language-sv", + taskType: "UPDATE_USER_MEMORY", + descriptionSv: "S6: UPDATE_USER_MEMORY på svenska returnerar svensk summary", + input: { + scope: "user", + scopeId: "user-1", + events: [{ id: "evt-sv", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }], + existingMemoryKeys: [], + }, + verify(output) { + const o = output as TaskOutput<"UPDATE_USER_MEMORY">; + return [ + check("minst ett minne returneras", o.memoryUpdates.length >= 1), + check("summarySv är på svenska", o.memoryUpdates.every((u) => /[åäöÅÄÖ]/.test(u.summarySv) || u.summarySv.length > 0)), + ]; + }, + }, + { + id: "provenance-no-forbidden-copy", + descriptionSv: "S6: genererade provenansförklaringar innehåller aldrig förbjuden copy", + run() { + const candidate: RecommendationCandidate = { + recipeId: "r-eval", + titleSv: "Krämig kycklinggryta", + cuisine: "swedish", + tags: [], + totalTimeMinutes: 30, + nutritionPerPortion: { kcal: 550, proteinG: 45, carbsG: 50, fatG: 18, saturatedFatG: 6, fiberG: 6, sugarG: 4, saltG: 1.5 }, + estimatedCostMinorPerPortion: 2200, + ratingAverage: null, + ratingCount: 0, + peakSeasons: ["summer"], + holidayTags: [], + spiceLevel: 1, + coverage: { coverage: 0.85, matches: [], missing: [], expiringUsed: [{ canonicalIngredientId: "chicken", displayNameSv: "kycklingen", required: 400, unit: "GRAM", availableInUnit: 500, covered: true, optional: false, mostUrgentDaysLeft: 2, usesExpiringItem: true }] }, + daysSinceLastCooked: 14, + householdRating: 4.8, + ingredientIds: ["chicken"], + }; + const context: RecommendationContext = { + mealType: "dinner", + persons: 4, + currentSeason: "summer", + activeHolidayTags: [], + isWeekday: true, + favoriteCuisines: ["swedish"], + remainingProteinG: 60, + remainingKcal: 800, + personalizationEnabled: true, + memoryItems: [{ 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() }], + tasteSignals: [], + cookingAssumptions: [{ canonicalIngredientId: "chicken", averageEatenPortions: 4, averageLeftoverPortions: 0.5, observationCount: 5 }], + }; + const whySv = buildWhy(candidate, context, { coverage: 0.85, expiry: 0.8, nutritionFit: 0.9, taste: 0.8, rating: 0.9, season: 1, holiday: 0, time: 1, budget: 0.6, variety: 1, weather: 0.5, craving: 0.5, memoryFit: 1, tasteFit: 0, cookingAssumptionFit: 0.8 }, [{ key: "favoriteCuisine", args: { cuisine: "svensk" } }, { key: "usesStapleYouFinish", args: { ingredient: "kyckling" } }], "sv-SE"); + const whyEn = buildWhy(candidate, context, { coverage: 0.85, expiry: 0.8, nutritionFit: 0.9, taste: 0.8, rating: 0.9, season: 1, holiday: 0, time: 1, budget: 0.6, variety: 1, weather: 0.5, craving: 0.5, memoryFit: 1, tasteFit: 0, cookingAssumptionFit: 0.8 }, [{ key: "favoriteCuisine", args: { cuisine: "Swedish" } }, { key: "usesStapleYouFinish", args: { ingredient: "chicken" } }], "en-US"); + return [ + check("whySv innehåller ingen förbjuden copy", !containsForbiddenCopy(whySv)), + check("whyEn innehåller ingen förbjuden copy", !containsForbiddenCopy(whyEn)), + check("whySv är på svenska", /Ni har|gram protein|berättat/.test(whySv)), + check("whyEn är på engelska", /You have|grams of protein|said/.test(whyEn)), + check("whySv och whyEn blandas inte", !/You have/.test(whySv) && !/Ni har/.test(whyEn)), + ]; + }, + }, ]; diff --git a/apps/worker/src/eval/run.ts b/apps/worker/src/eval/run.ts index 5056cd1..bcd492a 100644 --- a/apps/worker/src/eval/run.ts +++ b/apps/worker/src/eval/run.ts @@ -11,7 +11,7 @@ for (const candidate of [".env", "../.env", "../../.env"]) { import { schema } from "@app/database"; import { createContext } from "../context.js"; -import { EVAL_CASES } from "./cases.js"; +import { EVAL_CASES, isDeterministicEvalCase } from "./cases.js"; /** * Kör AAMOS-utvärderingssviten (spec §40). @@ -36,31 +36,36 @@ async function main() { let checks: { name: string; passed: boolean }[] = []; let error: string | null = null; try { - const result = await ctx.aamos.runTask(evalCase.taskType, evalCase.input as never); - if (result.status !== "ok" || !result.output) { - error = `status=${result.status}`; - checks = [{ name: "AAMOS svarade ok", passed: false }]; + if (isDeterministicEvalCase(evalCase)) { + checks = evalCase.run(); } else { - checks = [ - { name: "AAMOS svarade ok", passed: true }, - ...evalCase.verify(result.output as never), - ]; + const result = await ctx.aamos.runTask(evalCase.taskType, evalCase.input as never); + if (result.status !== "ok" || !result.output) { + error = `status=${result.status}`; + checks = [{ name: "AAMOS svarade ok", passed: false }]; + } else { + checks = [ + { name: "AAMOS svarade ok", passed: true }, + ...evalCase.verify(result.output as never), + ]; + } } } catch (err) { error = (err as Error).message; - checks = [{ name: "AAMOS svarade ok", passed: false }]; + checks = [{ name: "eval körde utan fel", passed: false }]; } const ms = Date.now() - started; const passed = checks.every((c) => c.passed); totalChecks += checks.length; failedChecks += checks.filter((c) => !c.passed).length; - console.log(`${passed ? "✓" : "✗"} ${evalCase.id} (${evalCase.taskType}, ${ms} ms)`); + const taskLabel = isDeterministicEvalCase(evalCase) ? "deterministic" : evalCase.taskType; + console.log(`${passed ? "✓" : "✗"} ${evalCase.id} (${taskLabel}, ${ms} ms)`); for (const c of checks) if (!c.passed) console.log(` ✗ ${c.name}`); if (error) console.log(` fel: ${error}`); await ctx.db.insert(schema.aiEvalRuns).values({ - taskType: evalCase.taskType, + taskType: isDeterministicEvalCase(evalCase) ? "DETERMINISTIC" : evalCase.taskType, modelVersion: `aamos-${mode}`, promptVersion: "eval-suite-v1", metrics: { diff --git a/packages/memory-client/src/index.ts b/packages/memory-client/src/index.ts index 0f61bb6..ba6dd01 100644 --- a/packages/memory-client/src/index.ts +++ b/packages/memory-client/src/index.ts @@ -97,8 +97,18 @@ export async function deriveMemoryUpdates( return { proposals: [], usage }; } + const validEventIds = new Set(input.events.map((e) => e.id)); + const groundedProposals = parsed.data.memoryUpdates.filter((u) => { + const grounded = u.sourceEventIds.length > 0 && u.sourceEventIds.every((id) => validEventIds.has(id)); + if (!grounded) { + // eslint-disable-next-line no-console + console.error(`UPDATE_USER_MEMORY avvisade fabricerat minne: key=${u.key}, sourceEventIds=[${u.sourceEventIds.join(", ")}]`); + } + return grounded; + }); + return { - proposals: parsed.data.memoryUpdates.map((u) => ({ + proposals: groundedProposals.map((u) => ({ key: u.key, kind: u.kind, summarySv: u.summarySv, @@ -295,6 +305,448 @@ const UI_LABELS: Record< }, }; +type MemoryValueRenderer = (value: Record) => string | null; + +const MEMORY_SUMMARY_TEMPLATES: Record< + string, + Record +> = { + sv: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Favoritkök: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Undviker ingrediens: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Primärt mål: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergi: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Max styrka: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Undviker: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Laggar vanligen ${v.typicalPortions} portioner` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Styrkepreferens: ${String(v.spicePreference)}` + : null, + }, + en: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Favorite cuisine: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Avoids ingredient: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Goal: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Primary goal: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergy: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Max spice level: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Avoids: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Usually cooks ${v.typicalPortions} servings` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Spice preference: ${String(v.spicePreference)}` + : null, + }, + es: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Cocina favorita: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Evita ingrediente: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Objetivo principal: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Alergia: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Nivel máximo de picante: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Evita: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Suele cocinar ${v.typicalPortions} raciones` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Preferencia de picante: ${String(v.spicePreference)}` + : null, + }, + it: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Cucina preferita: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Evita ingrediente: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Obiettivo: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Obiettivo principale: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergia: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Livello piccante max: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Evita: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Di solito cucina ${v.typicalPortions} porzioni` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Preferenza piccante: ${String(v.spicePreference)}` + : null, + }, + de: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Lieblingsküche: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Vermeidet Zutat: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Ziel: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Hauptziel: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergie: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Max. Schärfe: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Vermeidet: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Kocht meist ${v.typicalPortions} Portionen` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Schärfepräferenz: ${String(v.spicePreference)}` + : null, + }, + fr: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Cuisine préférée: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Évite l'ingrédient: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Objectif: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Objectif principal: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergie: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Niveau épicé max: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Évite: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Cuisine habituellement ${v.typicalPortions} portions` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Préférence épicée: ${String(v.spicePreference)}` + : null, + }, + da: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Yndlingskøkken: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Undgår ingrediens: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Primært mål: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergi: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Max styrke: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Undgår: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Tilbereder som regel ${v.typicalPortions} portioner` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Styrkepræference: ${String(v.spicePreference)}` + : null, + }, + nb: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Favorittkjøkken: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Unngår ingrediens: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Primært mål: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergi: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Maks styrke: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Unngår: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Lager vanligvis ${v.typicalPortions} porsjoner` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Styrkepreferanse: ${String(v.spicePreference)}` + : null, + }, + fi: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Suosikkikeittiö: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Vältettävä ainesosa: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Tavoite: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Päätavoite: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergia: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Max tulisuus: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Vältää: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Valmistaa yleensä ${v.typicalPortions} annosta` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Tulisuusmieltymys: ${String(v.spicePreference)}` + : null, + }, + nl: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Favoriete keuken: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Vermijdt ingrediënt: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Doel: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Primair doel: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Allergie: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Max pittigheid: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Vermijdt: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Kookt meestal ${v.typicalPortions} porties` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Pittigheidspreferentie: ${String(v.spicePreference)}` + : null, + }, + pl: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Ulubiona kuchnia: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Unika składnika: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Cel: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Cel główny: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Alergia: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Maks. ostrość: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Unika: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Zwykle gotuje ${v.typicalPortions} porcje` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Preferencja ostrości: ${String(v.spicePreference)}` + : null, + }, + pt: { + favoriteCuisine: (v) => + typeof v.favoriteCuisine === "string" + ? `Cozinha favorita: ${String(v.favoriteCuisine)}` + : null, + avoidIngredient: (v) => + typeof v.avoidIngredientId === "string" + ? `Evita ingrediente: ${String(v.avoidIngredientId)}` + : null, + goal: (v) => + typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null, + primaryGoal: (v) => + typeof v.primaryGoal === "string" + ? `Objetivo principal: ${String(v.primaryGoal)}` + : null, + allergen: (v) => + typeof v.allergen === "string" + ? `Alergia: ${String(v.allergen)}` + : null, + spiceLevelMax: (v) => + typeof v.spiceLevelMax === "number" + ? `Nível picante máx.: ${v.spiceLevelMax}` + : null, + dislikedIngredient: (v) => + typeof v.dislikedIngredient === "string" + ? `Evita: ${String(v.dislikedIngredient)}` + : null, + typicalPortions: (v) => + typeof v.typicalPortions === "number" + ? `Costuma cozinhar ${v.typicalPortions} porções` + : null, + spicePreference: (v) => + typeof v.spicePreference === "string" + ? `Preferência picante: ${String(v.spicePreference)}` + : null, + }, +}; + +const SUPPORTED_MEMORY_LANGS = Object.keys(MEMORY_SUMMARY_TEMPLATES); + /** * Deterministisk rendering av summary ur strukturerad value (i18n-spec §23, M10). * Kända value-former renderas per språk; okända faller tillbaka på summarySv @@ -304,41 +756,55 @@ export function renderMemorySummary( item: Pick, languageTag: string, ): string { - const lang = languageTag.split("-")[0] ?? "sv"; - if (lang === "sv") return item.summarySv; + const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase(); + const catalog = MEMORY_SUMMARY_TEMPLATES[lang] ?? MEMORY_SUMMARY_TEMPLATES.sv!; const v = item.value as Record | null | undefined; if (v && typeof v === "object") { - if (typeof v.favoriteCuisine === "string") { - return `Favorite cuisine: ${String(v.favoriteCuisine)}`; + if (typeof v.favoriteCuisine === "string" && catalog.favoriteCuisine) { + const rendered = catalog.favoriteCuisine(v); + if (rendered) return rendered; } - if (typeof v.dislikedIngredient === "string") { - return `Avoids: ${String(v.dislikedIngredient)}`; + if (typeof v.avoidIngredientId === "string" && catalog.avoidIngredient) { + const rendered = catalog.avoidIngredient(v); + if (rendered) return rendered; } - if (typeof v.typicalPortions === "number") { - return `Usually cooks ${v.typicalPortions} servings`; + if (typeof v.goal === "string" && catalog.goal) { + const rendered = catalog.goal(v); + if (rendered) return rendered; } - if (typeof v.spicePreference === "string") { - return `Spice preference: ${String(v.spicePreference)}`; + if (typeof v.primaryGoal === "string" && catalog.primaryGoal) { + const rendered = catalog.primaryGoal(v); + if (rendered) return rendered; } - if (typeof v.goal === "string") { - return `Goal: ${String(v.goal)}`; + if (typeof v.allergen === "string" && catalog.allergen) { + const rendered = catalog.allergen(v); + if (rendered) return rendered; } - if (typeof v.primaryGoal === "string") { - return `Primary goal: ${String(v.primaryGoal)}`; + if (typeof v.spiceLevelMax === "number" && catalog.spiceLevelMax) { + const rendered = catalog.spiceLevelMax(v); + if (rendered) return rendered; } - if (typeof v.allergen === "string") { - return `Allergy: ${String(v.allergen)}`; + if (typeof v.dislikedIngredient === "string" && catalog.dislikedIngredient) { + const rendered = catalog.dislikedIngredient(v); + if (rendered) return rendered; } - if (typeof v.avoidIngredientId === "string") { - return `Avoids ingredient: ${String(v.avoidIngredientId)}`; + if (typeof v.typicalPortions === "number" && catalog.typicalPortions) { + const rendered = catalog.typicalPortions(v); + if (rendered) return rendered; } - if (typeof v.spiceLevelMax === "number") { - return `Max spice level: ${v.spiceLevelMax}`; + if (typeof v.spicePreference === "string" && catalog.spicePreference) { + const rendered = catalog.spicePreference(v); + if (rendered) return rendered; } } return item.summarySv; } +/** Språk som stöds av renderMemorySummary. */ +export function supportedMemorySummaryLanguages(): string[] { + return [...SUPPORTED_MEMORY_LANGS]; +} + export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): MemoryOverview { const lang = languageTag.split("-")[0] ?? "sv"; const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!; diff --git a/packages/memory-client/test/overview.test.ts b/packages/memory-client/test/overview.test.ts index 22c6e7e..2fda261 100644 --- a/packages/memory-client/test/overview.test.ts +++ b/packages/memory-client/test/overview.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { buildMemoryOverview } from "../src/index.js"; +import { buildMemoryOverview, deriveMemoryUpdates, renderMemorySummary } from "../src/index.js"; +import type { AamosClient, AamosResult } from "@app/ai-contracts"; +import type { AamosTaskType } from "@app/ai-contracts"; import type { MemoryItem } from "@app/shared-types"; function makeItem(overrides: Partial = {}): MemoryItem { @@ -79,4 +81,86 @@ describe("buildMemoryOverview", () => { expect(item.pausedLabel).toBe("Paused"); expect(item.summary).toBe("Favorite cuisine: swedish"); }); + + it("renderMemorySummary stödjer S5-value-former", () => { + expect(renderMemorySummary({ summarySv: "", value: { favoriteCuisine: "thai" } }, "en-US")).toBe("Favorite cuisine: thai"); + expect(renderMemorySummary({ summarySv: "", value: { avoidIngredientId: "broccoli" } }, "en-US")).toBe("Avoids ingredient: broccoli"); + expect(renderMemorySummary({ summarySv: "", value: { goal: "less_waste" } }, "en-US")).toBe("Goal: less_waste"); + expect(renderMemorySummary({ summarySv: "", value: { primaryGoal: "less_waste" } }, "en-US")).toBe("Primary goal: less_waste"); + expect(renderMemorySummary({ summarySv: "", value: { allergen: "gluten" } }, "en-US")).toBe("Allergy: gluten"); + expect(renderMemorySummary({ summarySv: "", value: { spiceLevelMax: 2 } }, "en-US")).toBe("Max spice level: 2"); + }); +}); + +describe("deriveMemoryUpdates", () => { + function fakeAamos(output: unknown): AamosClient { + return { + async runTask(): Promise> { + return { + status: "ok", + output: output as AamosResult["output"], + modelVersion: "fake", + promptVersion: "fake", + latencyMs: 10, + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + }; + }, + async healthCheck() { + return { ok: true }; + }, + }; + } + + it("avvisar fabricerade minnen utan event-stöd", async () => { + const aamos = fakeAamos({ + memoryUpdates: [ + { + key: "fabricated", + kind: "structured_fact", + summarySv: "Påhittad favoriträtt", + value: { recipeId: "ghost" }, + origin: "ai_inferred", + confidence: 0.6, + expiresAt: null, + sourceEventIds: ["event-does-not-exist"], + }, + ], + }); + const result = await deriveMemoryUpdates(aamos, { + scope: "user", + scopeId: "u1", + events: [{ id: "evt-real", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }], + existingMemoryKeys: [], + consentFlags: { personalization: true, anonymizedImprovement: false, imageTraining: false }, + }); + expect(result.proposals).toHaveLength(0); + }); + + it("behåller grundade minnen med kända event-id", async () => { + const aamos = fakeAamos({ + memoryUpdates: [ + { + key: "grounded", + kind: "structured_fact", + summarySv: "Ggrundat minne", + value: { recipeId: "r1" }, + origin: "observed", + confidence: 0.8, + expiresAt: null, + sourceEventIds: ["evt-real"], + }, + ], + }); + const result = await deriveMemoryUpdates(aamos, { + scope: "user", + scopeId: "u1", + events: [{ id: "evt-real", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }], + existingMemoryKeys: [], + consentFlags: { personalization: true, anonymizedImprovement: false, imageTraining: false }, + }); + expect(result.proposals).toHaveLength(1); + expect(result.proposals[0]!.key).toBe("grounded"); + }); }); diff --git a/packages/recommendation-engine/src/explain.ts b/packages/recommendation-engine/src/explain.ts index b70eb60..9a86210 100644 --- a/packages/recommendation-engine/src/explain.ts +++ b/packages/recommendation-engine/src/explain.ts @@ -5,30 +5,272 @@ import type { } from "./types.js"; import { renderProvenance } from "./provenance-templates.js"; +import type { LanguageTag } from "./provenance-templates.js"; + +interface WhyTemplates { + coverage100: string; + coveragePct: string; + coverageLow: string; + expiringToday: string; + expiringTomorrow: string; + expiringDays: string; + protein: string; + trainingDay: string; + householdRating: string; + holiday: string; + season: string; + time: string; + budget: string; + craving: string; + fallback: string; +} + +const WHY_TEMPLATES: Record = { + sv: { + coverage100: "Ni har alla ingredienser hemma.", + coveragePct: "Ni har {{pct}} % av ingredienserna hemma.", + coverageLow: "Ni har {{pct}} % av ingredienserna – resten hamnar på inköpslistan.", + expiringToday: "{{ingredient}} bör användas i dag.", + expiringTomorrow: "{{ingredient}} bör användas senast i morgon.", + expiringDays: "{{ingredient}} bör användas inom {{days}} dagar.", + protein: "Rätten ger {{protein}} gram protein per portion.", + trainingDay: "Bra val på en träningsdag.", + householdRating: "Liknande rätter har fått höga betyg av hushållet.", + holiday: "Passar den kommande högtiden.", + season: "Råvarorna är i säsong just nu.", + time: "Klar på {{minutes}} minuter.", + budget: "Cirka {{cost}} kr per portion.", + craving: "Matchar det du är sugen på.", + fallback: "En balanserad rätt som passar er profil.", + }, + en: { + coverage100: "You have all the ingredients at home.", + coveragePct: "You have {{pct}} % of the ingredients at home.", + coverageLow: "You have {{pct}} % of the ingredients – the rest goes on the shopping list.", + expiringToday: "{{ingredient}} should be used today.", + expiringTomorrow: "{{ingredient}} should be used by tomorrow.", + expiringDays: "{{ingredient}} should be used within {{days}} days.", + protein: "The dish provides {{protein}} grams of protein per portion.", + trainingDay: "A good choice for a training day.", + householdRating: "Similar dishes have been rated highly by your household.", + holiday: "Fits the upcoming holiday.", + season: "The ingredients are in season right now.", + time: "Ready in {{minutes}} minutes.", + budget: "About {{cost}} per portion.", + craving: "Matches what you’re craving.", + fallback: "A balanced dish that fits your profile.", + }, + es: { + coverage100: "Tenéis todos los ingredientes en casa.", + coveragePct: "Tenéis el {{pct}} % de los ingredientes en casa.", + coverageLow: "Tenéis el {{pct}} % de los ingredientes; el resto irá a la lista de la compra.", + expiringToday: "{{ingredient}} debería usarse hoy.", + expiringTomorrow: "{{ingredient}} debería usarse como máximo mañana.", + expiringDays: "{{ingredient}} debería usarse en {{days}} días.", + protein: "El plato aporta {{protein}} gramos de proteína por ración.", + trainingDay: "Buena elección para un día de entrenamiento.", + householdRating: "Platos similares han sido valorados positivamente por tu hogar.", + holiday: "Encaja con la próxima festividad.", + season: "Los ingredientes están de temporada ahora mismo.", + time: "Listo en {{minutes}} minutos.", + budget: "Aproximadamente {{cost}} por ración.", + craving: "Encaja con lo que te apetece.", + fallback: "Un plato equilibrado que encaja con tu perfil.", + }, + it: { + coverage100: "Avete tutti gli ingredienti a casa.", + coveragePct: "Avete il {{pct}} % degli ingredienti a casa.", + coverageLow: "Avete il {{pct}} % degli ingredienti; il resto finisce sulla lista della spesa.", + expiringToday: "{{ingredient}} va usato oggi.", + expiringTomorrow: "{{ingredient}} va usato al più tardi domani.", + expiringDays: "{{ingredient}} va usato entro {{days}} giorni.", + protein: "Il piatto fornisce {{protein}} grammi di proteine a porzione.", + trainingDay: "Buona scelta per un giorno di allenamento.", + householdRating: "Piatti simili sono stati valutati positivamente dalla tua famiglia.", + holiday: "Perfetto per la prossima festività.", + season: "Gli ingredienti sono di stagione in questo momento.", + time: "Pronto in {{minutes}} minuti.", + budget: "Circa {{cost}} a porzione.", + craving: "Corrisponde a ciò che ti va.", + fallback: "Un piatto equilibrato che si adatta al tuo profilo.", + }, + de: { + coverage100: "Ihr habt alle Zutaten zu Hause.", + coveragePct: "Ihr habt {{pct}} % der Zutaten zu Hause.", + coverageLow: "Ihr habt {{pct}} % der Zutaten; der Rest kommt auf die Einkaufsliste.", + expiringToday: "{{ingredient}} sollte heute verwendet werden.", + expiringTomorrow: "{{ingredient}} sollte spätestens morgen verwendet werden.", + expiringDays: "{{ingredient}} sollte innerhalb von {{days}} Tagen verwendet werden.", + protein: "Das Gericht liefert {{protein}} Gramm Protein pro Portion.", + trainingDay: "Gute Wahl für einen Trainingstag.", + householdRating: "Ähnliche Gerichte wurden von deinem Haushalt gut bewertet.", + holiday: "Passt zum bevorstehenden Feiertag.", + season: "Die Zutaten sind gerade saisonal.", + time: "Fertig in {{minutes}} Minuten.", + budget: "Etwa {{cost}} pro Portion.", + craving: "Passt zu dem, worauf du Lust hast.", + fallback: "Ein ausgewogenes Gericht, das zu deinem Profil passt.", + }, + fr: { + coverage100: "Vous avez tous les ingrédients à la maison.", + coveragePct: "Vous avez {{pct}} % des ingrédients à la maison.", + coverageLow: "Vous avez {{pct}} % des ingrédients; le reste ira sur la liste de courses.", + expiringToday: "{{ingredient}} devrait être utilisé aujourd'hui.", + expiringTomorrow: "{{ingredient}} devrait être utilisé au plus tard demain.", + expiringDays: "{{ingredient}} devrait être utilisé dans {{days}} jours.", + protein: "Le plat apporte {{protein}} grammes de protéines par portion.", + trainingDay: "Bon choix pour un jour d'entraînement.", + householdRating: "Des plats similaires ont été bien notés par votre foyer.", + holiday: "Convient à la prochaine fête.", + season: "Les ingrédients sont de saison en ce moment.", + time: "Prêt en {{minutes}} minutes.", + budget: "Environ {{cost}} par portion.", + craving: "Correspond à ce dont vous avez envie.", + fallback: "Un plat équilibré qui correspond à votre profil.", + }, + da: { + coverage100: "I har alle ingredienserne hjemme.", + coveragePct: "I har {{pct}} % af ingredienserne hjemme.", + coverageLow: "I har {{pct}} % af ingredienserne; resten kommer på indkøbslisten.", + expiringToday: "{{ingredient}} bør bruges i dag.", + expiringTomorrow: "{{ingredient}} bør bruges senest i morgen.", + expiringDays: "{{ingredient}} bør bruges inden for {{days}} dage.", + protein: "Retten giver {{protein}} gram protein pr. portion.", + trainingDay: "Et godt valg på en træningsdag.", + householdRating: "Lignende retter er blevet bedømt højt af husstanden.", + holiday: "Passer til den kommende højtid.", + season: "Råvarerne er i sæson lige nu.", + time: "Klar på {{minutes}} minutter.", + budget: "Cirka {{cost}} pr. portion.", + craving: "Matcher det, du har lyst til.", + fallback: "En balanceret ret, der passer til din profil.", + }, + nb: { + coverage100: "Dere har alle ingrediensene hjemme.", + coveragePct: "Dere har {{pct}} % av ingrediensene hjemme.", + coverageLow: "Dere har {{pct}} % av ingrediensene; resten kommer på handlelisten.", + expiringToday: "{{ingredient}} bør brukes i dag.", + expiringTomorrow: "{{ingredient}} bør brukes senest i morgen.", + expiringDays: "{{ingredient}} bør brukes innen {{days}} dager.", + protein: "Retten gir {{protein}} gram protein per porsjon.", + trainingDay: "Et godt valg på en treningsdag.", + householdRating: "Lignende retter er blitt vurdert høyt av husstanden.", + holiday: "Passer til den kommende høytiden.", + season: "Råvarene er i sesong akkurat nå.", + time: "Klar på {{minutes}} minutter.", + budget: "Omtrent {{cost}} per porsjon.", + craving: "Matcher det du har lyst på.", + fallback: "En balansert rett som passer profilen din.", + }, + fi: { + coverage100: "Teillä on kaikki ainekset kotona.", + coveragePct: "Teillä on {{pct}} % aineksista kotona.", + coverageLow: "Teillä on {{pct}} % aineksista; loput menevät ostoslistalle.", + expiringToday: "{{ingredient}} tulisi käyttää tänään.", + expiringTomorrow: "{{ingredient}} tulisi käyttää viimeistään huomenna.", + expiringDays: "{{ingredient}} tulisi käyttää {{days}} päivän kuluessa.", + protein: "Ruoka antaa {{protein}} grammaa proteiinia annosta kohti.", + trainingDay: "Hyvä valinta treenipäivälle.", + householdRating: "Samankaltaiset ruoat on arvioitu korkealle taloudessasi.", + holiday: "Sopii lähestyvään juhlapäivään.", + season: "Ainekset ovat sesongissa juuri nyt.", + time: "Valmista {{minutes}} minuutissa.", + budget: "Noin {{cost}} annosta kohti.", + craving: "Vastaa sitä, mitä haluat.", + fallback: "Tasapainoinen ruoka, joka sopii profiilisi.", + }, + nl: { + coverage100: "Jullie hebben alle ingrediënten thuis.", + coveragePct: "Jullie hebben {{pct}} % van de ingrediënten thuis.", + coverageLow: "Jullie hebben {{pct}} % van de ingrediënten; de rest komt op het boodschappenlijstje.", + expiringToday: "{{ingredient}} moet vandaag gebruikt worden.", + expiringTomorrow: "{{ingredient}} moet uiterlijk morgen gebruikt worden.", + expiringDays: "{{ingredient}} moet binnen {{days}} dagen gebruikt worden.", + protein: "Het gerecht levert {{protein}} gram eiwit per portie.", + trainingDay: "Een goede keuze voor een trainingdag.", + householdRating: "Vergelijkbare gerechten zijn hoog beoordeeld door je huishouden.", + holiday: "Past bij de komende feestdag.", + season: "De ingrediënten zijn nu in het seizoen.", + time: "Klaar in {{minutes}} minuten.", + budget: "Ongeveer {{cost}} per portie.", + craving: "Past bij waar je zin in hebt.", + fallback: "Een evenwichtig gerecht dat bij je profiel past.", + }, + pl: { + coverage100: "Macie w domu wszystkie składniki.", + coveragePct: "Macie w domu {{pct}} % składników.", + coverageLow: "Macie w domu {{pct}} % składników; reszta trafi na listę zakupów.", + expiringToday: "{{ingredient}} należy użyć dzisiaj.", + expiringTomorrow: "{{ingredient}} należy użyć najpóźniej jutro.", + expiringDays: "{{ingredient}} należy użyć w ciągu {{days}} dni.", + protein: "Danie dostarcza {{protein}} gramów białka na porcję.", + trainingDay: "Dobry wybór na dzień treningowy.", + householdRating: "Podobne dania zostały wysoko ocenione przez Twoje gospodarstwo domowe.", + holiday: "Pasuje do zbliżającego się święta.", + season: "Składniki są teraz w sezonie.", + time: "Gotowe w {{minutes}} minut.", + budget: "Około {{cost}} za porcję.", + craving: "Pasuje do tego, na co masz ochotę.", + fallback: "Zrównoważone danie, które pasuje do Twojego profilu.", + }, + pt: { + coverage100: "Tens todos os ingredientes em casa.", + coveragePct: "Tens {{pct}} % dos ingredientes em casa.", + coverageLow: "Tens {{pct}} % dos ingredientes; o resto vai para a lista de compras.", + expiringToday: "{{ingredient}} deve ser usado hoje.", + expiringTomorrow: "{{ingredient}} deve ser usado até amanhã.", + expiringDays: "{{ingredient}} deve ser usado em {{days}} dias.", + protein: "O prato fornece {{protein}} gramas de proteína por porção.", + trainingDay: "Boa escolha para um dia de treino.", + householdRating: "Pratos semelhantes foram bem avaliados pelo teu agregado.", + holiday: "Combina com o próximo feriado.", + season: "Os ingredientes estão na época neste momento.", + time: "Pronto em {{minutes}} minutos.", + budget: "Aproximadamente {{cost}} por porção.", + craving: "Combina com o que te apetece.", + fallback: "Um prato equilibrado que se adequa ao teu perfil.", + }, +}; + +function getTemplates(lang: string): WhyTemplates { + return WHY_TEMPLATES[(lang.split("-")[0] ?? "sv") as LanguageTag] ?? WHY_TEMPLATES.sv; +} + +/** Returnerar de språk som why-mallarna täcker. */ +export function whyTemplateLanguages(): LanguageTag[] { + return Object.keys(WHY_TEMPLATES) as LanguageTag[]; +} + +function render(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(vars[key] ?? "")); +} + /** * "Varför rekommenderar vi detta?" (spec §18, S1). - * Bygger en ärlig, konkret svensk förklaring ur: + * Bygger en ärlig, konkret 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( +export function buildWhy( candidate: RecommendationCandidate, ctx: RecommendationContext, parts: Record, provenance: ProvenanceEntry[] = [], + languageTag = "sv-SE", ): string { + const t = getTemplates(languageTag); const sentences: string[] = []; const pct = Math.round(candidate.coverage.coverage * 100); if (pct >= 100) { - sentences.push("Ni har alla ingredienser hemma."); + sentences.push(t.coverage100); } else if (pct >= 60) { - sentences.push(`Ni har ${pct} % av ingredienserna hemma.`); + sentences.push(render(t.coveragePct, { pct })); } else if (pct > 0) { - sentences.push(`Ni har ${pct} % av ingredienserna – resten hamnar på inköpslistan.`); + sentences.push(render(t.coverageLow, { pct })); } const urgent = candidate.coverage.expiringUsed @@ -36,59 +278,68 @@ export function buildWhySv( .sort((a, b) => (a.mostUrgentDaysLeft ?? 99) - (b.mostUrgentDaysLeft ?? 99))[0]; if (urgent) { const days = urgent.mostUrgentDaysLeft ?? 0; - const when = days <= 0 ? "i dag" : days === 1 ? "senast i morgon" : `inom ${days} dagar`; - sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`); + const tpl = days <= 0 ? t.expiringToday : days === 1 ? t.expiringTomorrow : t.expiringDays; + sentences.push(render(tpl, { ingredient: capitalize(urgent.displayNameSv), days })); } // 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.`); + sentences.push(render(t.protein, { protein })); } if (ctx.isTrainingDay && protein >= 35) { - sentences.push("Bra val på en träningsdag."); + sentences.push(t.trainingDay); } if ((parts.rating ?? 0) >= 0.8 && candidate.householdRating != null) { - sentences.push("Liknande rätter har fått höga betyg av hushållet."); + sentences.push(t.householdRating); } if ((parts.holiday ?? 0) >= 1 && ctx.activeHolidayTags.length > 0) { - sentences.push("Passar den kommande högtiden."); + sentences.push(t.holiday); } else if ((parts.season ?? 0) >= 1) { - sentences.push("Råvarorna är i säsong just nu."); + sentences.push(t.season); } if ((parts.time ?? 0) >= 1 && ctx.isWeekday) { - sentences.push(`Klar på ${candidate.totalTimeMinutes} minuter.`); + sentences.push(render(t.time, { minutes: candidate.totalTimeMinutes })); } if ((parts.budget ?? 0) >= 1 && candidate.estimatedCostMinorPerPortion != null) { sentences.push( - `Cirka ${Math.round(candidate.estimatedCostMinorPerPortion / 100)} kr per portion.`, + render(t.budget, { cost: Math.round(candidate.estimatedCostMinorPerPortion / 100) }), ); } if ((parts.craving ?? 0) >= 1) { - sentences.push("Matchar det du är sugen på."); + sentences.push(t.craving); } // 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 + const rendered = renderProvenance(provenance.slice(0, 2), languageTag); // max 2 personliga satser if (rendered) { sentences.push(rendered); } } if (sentences.length === 0) { - sentences.push("En balanserad rätt som passar er profil."); + sentences.push(t.fallback); } return sentences.join(" "); } +/** Bakåtkompatibel wrapper: svensk whySv. */ +export function buildWhySv( + candidate: RecommendationCandidate, + ctx: RecommendationContext, + parts: Record, + provenance: ProvenanceEntry[] = [], +): string { + return buildWhy(candidate, ctx, parts, provenance, "sv-SE"); +} + function capitalize(s: string): string { return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1); } diff --git a/packages/recommendation-engine/src/provenance-templates.ts b/packages/recommendation-engine/src/provenance-templates.ts index 184b2b0..1be0399 100644 --- a/packages/recommendation-engine/src/provenance-templates.ts +++ b/packages/recommendation-engine/src/provenance-templates.ts @@ -160,6 +160,15 @@ export function renderProvenanceList( .filter((s): s is string => s != null); } +/** Språk som varje proveniensmall är översatt till. */ +export function provenanceTemplateLanguages(): Record { + const result: Record = {}; + for (const [key, t] of Object.entries(TEMPLATES)) { + result[key] = Object.keys(t) as LanguageTag[]; + } + return result; +} + /** Förbjudna ord/fraser i proveniens/copy — R3 (ingen skam) + R7 (välmående). */ export const FORBIDDEN_COPY_PATTERNS = [ /överskrid/i, diff --git a/packages/recommendation-engine/test/explanation-copy.test.ts b/packages/recommendation-engine/test/explanation-copy.test.ts new file mode 100644 index 0000000..a8ace99 --- /dev/null +++ b/packages/recommendation-engine/test/explanation-copy.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { + buildWhy, + containsForbiddenCopy, + FORBIDDEN_COPY_PATTERNS, + type ProvenanceEntry, + type RecommendationCandidate, + type RecommendationContext, +} from "../src/index.js"; + +const nutrition = { + kcal: 550, + proteinG: 45, + carbsG: 50, + fatG: 18, + saturatedFatG: 6, + fiberG: 6, + sugarG: 4, + saltG: 1.5, +}; + +function candidate(overrides: Partial = {}): RecommendationCandidate { + return { + recipeId: "r1", + titleSv: "Kycklinggryta", + cuisine: "swedish", + tags: [], + totalTimeMinutes: 30, + nutritionPerPortion: nutrition, + estimatedCostMinorPerPortion: 2200, + ratingAverage: null, + ratingCount: 0, + peakSeasons: ["summer"], + holidayTags: [], + spiceLevel: 1, + coverage: { coverage: 0.75, matches: [], missing: [], expiringUsed: [] }, + ...overrides, + }; +} + +const baseCtx: RecommendationContext = { + mealType: "dinner", + persons: 4, + currentSeason: "summer", + activeHolidayTags: [], + isWeekday: true, + favoriteCuisines: ["swedish"], + remainingProteinG: 60, + remainingKcal: 800, + personalizationEnabled: true, +}; + +const provenanceSet: ProvenanceEntry[][] = [ + [], + [{ key: "favoriteCuisine", args: { cuisine: "svensk" } }], + [{ key: "cookedOften", args: { recipe: "pannkakor", count: 3 } }], + [{ key: "expiringIngredient", args: { count: 2, ingredient: "gurkan", days: 1 } }], + [{ key: "fitsProteinGoal", args: {} }], + [{ key: "fitsVegetableGoal", args: {} }], + [{ key: "tastePreference", args: { axis: "spice" } }], + [{ key: "usesStapleYouFinish", args: { ingredient: "ris" } }], +]; + +describe("S6 förbjuden-copy-skanning av genererade förklaringar", () => { + it("100+ varierade förklaringar innehåller ingen förbjuden copy", () => { + const explanations: string[] = []; + const langs = ["sv-SE", "en-US", "de-DE", "es-ES", "fr-FR"]; + const coverages = [0.25, 0.55, 0.75, 0.92, 1]; + const proteins = [15, 30, 45, 58]; + const ratings = [null, 4.5, 4.9]; + const costs = [1200, 2200, 4500]; + const daysSince = [null, 2, 14]; + + for (const lang of langs) { + for (const cov of coverages) { + for (const protein of proteins) { + for (const rating of ratings) { + for (const cost of costs) { + for (const days of daysSince) { + for (const prov of provenanceSet) { + const ctx: RecommendationContext = { + ...baseCtx, + isTrainingDay: protein >= 35, + activeHolidayTags: lang === "sv-SE" ? ["midsummer"] : [], + }; + const c = candidate({ + coverage: { + coverage: cov, + matches: [], + missing: [], + expiringUsed: + cov < 1 + ? [ + { + canonicalIngredientId: "chicken", + displayNameSv: "kycklingen", + required: 400, + unit: "GRAM", + availableInUnit: 500, + covered: true, + optional: false, + mostUrgentDaysLeft: 2, + usesExpiringItem: true, + }, + ] + : [], + }, + nutritionPerPortion: { ...nutrition, proteinG: protein }, + estimatedCostMinorPerPortion: cost, + householdRating: rating, + daysSinceLastCooked: days, + }); + const parts = { + coverage: cov, + expiry: cov < 1 ? 0.8 : 0, + nutritionFit: protein >= 25 ? 0.85 : 0.4, + taste: 0.6, + rating: rating != null ? 0.9 : 0.5, + season: 1, + holiday: ctx.activeHolidayTags.length > 0 ? 1 : 0, + time: 1, + budget: cost <= 2500 ? 1 : 0.4, + variety: days == null ? 0.8 : days < 7 ? 0 : 1, + weather: 0.5, + craving: 0.5, + memoryFit: 1, + tasteFit: 0, + cookingAssumptionFit: 0, + }; + explanations.push(buildWhy(c, ctx, parts, prov, lang)); + } + } + } + } + } + } + } + + expect(explanations.length).toBeGreaterThanOrEqual(100); + const hits = explanations.filter((e) => containsForbiddenCopy(e)); + if (hits.length > 0) { + // eslint-disable-next-line no-console + console.error("Förbjuden copy hittades:", hits.slice(0, 5)); + } + expect(hits).toHaveLength(0); + }); + + it("skam-exempel-korpus flaggas av förbjuden-copy-listan", () => { + const shameExamples = [ + "Du har överskridit ditt kalorimål.", + "Bara 200 kcal kvar idag.", + "Begränsa dig nu.", + "Undvik kolhydrater.", + "Du borde inte äta så mycket kött.", + "Skäms över dina matvanor.", + "Din kost är dålig.", + "Du har ätit för mycket socker.", + "Måste sluta med snacks.", + "Överdriv inte.", + "Du får inte äta det där.", + ]; + const flagged = shameExamples.filter((s) => containsForbiddenCopy(s)); + expect(flagged.length).toBeGreaterThanOrEqual(shameExamples.length - 1); + }); + + it("varje förbjudet mönster matchar minst ett skam-exempel", () => { + const shameExamples = [ + "Du har överskridit ditt kalorimål.", + "Bara 200 kcal kvar idag.", + "Begränsa dig nu.", + "Undvik kolhydrater.", + "Du borde inte äta så mycket kött.", + "Skäms över dina matvanor.", + "Din kost är dålig.", + "Du har ätit för mycket socker.", + "Måste sluta med snacks.", + "Överdriv inte.", + "Du får inte äta det där.", + "För lite grönsaker idag.", + "För mycket fett.", + "Skärp dig.", + ]; + for (const pattern of FORBIDDEN_COPY_PATTERNS) { + const matches = shameExamples.filter((s) => pattern.test(s)); + expect(matches.length, `mönster ${pattern.source} matchade inget exempel`).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/recommendation-engine/test/i18n-parity.test.ts b/packages/recommendation-engine/test/i18n-parity.test.ts new file mode 100644 index 0000000..1b706de --- /dev/null +++ b/packages/recommendation-engine/test/i18n-parity.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + provenanceTemplateLanguages, + whyTemplateLanguages, + buildWhy, + type RecommendationCandidate, + type RecommendationContext, + type ProvenanceEntry, +} from "../src/index.js"; + +const EXPECTED_LANGS = [ + "sv", + "en", + "es", + "it", + "de", + "fr", + "da", + "nb", + "fi", + "nl", + "pl", + "pt", +]; + +const fullCoverage = { coverage: 1, matches: [], missing: [], expiringUsed: [] }; +const nutrition = { + kcal: 550, + proteinG: 45, + carbsG: 50, + fatG: 18, + saturatedFatG: 6, + fiberG: 6, + sugarG: 4, + saltG: 1.5, +}; + +function candidate(overrides: Partial = {}): RecommendationCandidate { + return { + recipeId: "r1", + titleSv: "Kycklinggryta", + cuisine: "swedish", + tags: [], + totalTimeMinutes: 30, + nutritionPerPortion: nutrition, + estimatedCostMinorPerPortion: 2200, + ratingAverage: null, + ratingCount: 0, + peakSeasons: [], + holidayTags: [], + spiceLevel: 1, + coverage: fullCoverage, + ...overrides, + }; +} + +const ctx: RecommendationContext = { + mealType: "dinner", + persons: 4, + currentSeason: "summer", + activeHolidayTags: [], + isWeekday: true, + favoriteCuisines: ["swedish"], + remainingProteinG: 60, + remainingKcal: 800, + personalizationEnabled: false, +}; + +describe("S6 i18n-paritet", () => { + it("provenansmallarna täcker exakt 12 språk utan dubbletter", () => { + const langsByTemplate = provenanceTemplateLanguages(); + expect(Object.keys(langsByTemplate).length).toBeGreaterThan(0); + for (const [key, langs] of Object.entries(langsByTemplate)) { + expect(langs.length, `mall ${key} har dubbletter eller saknar språk`).toBe(EXPECTED_LANGS.length); + expect(new Set(langs).size, `mall ${key} har dubbletter`).toBe(EXPECTED_LANGS.length); + for (const lang of EXPECTED_LANGS) { + expect(langs).toContain(lang); + } + } + }); + + it("why-mallarna täcker exakt 12 språk utan dubbletter", () => { + const langs = whyTemplateLanguages(); + expect(langs.length).toBe(EXPECTED_LANGS.length); + expect(new Set(langs).size).toBe(EXPECTED_LANGS.length); + for (const lang of EXPECTED_LANGS) { + expect(langs).toContain(lang); + } + }); + + it("buildWhy renderar på rätt språk för varje supporterat språk", () => { + const provenance: ProvenanceEntry[] = [ + { key: "favoriteCuisine", args: { cuisine: "svensk" } }, + ]; + for (const lang of EXPECTED_LANGS) { + const why = buildWhy(candidate(), { ...ctx, personalizationEnabled: true }, { coverage: 1, expiry: 0, nutritionFit: 0.9, taste: 0.5, rating: 0.5, season: 0, holiday: 0, time: 0, budget: 0, variety: 0.5, weather: 0.5, craving: 0.5, memoryFit: 1, tasteFit: 0, cookingAssumptionFit: 0 }, provenance, `${lang}-XX`); + expect(why.length, `tom why för ${lang}`).toBeGreaterThan(0); + // Ingen why får falla tillbaka på svenska fallback om språket finns. + if (lang !== "sv") { + expect(why).not.toBe("En balanserad rätt som passar er profil."); + } + } + }); +});