S6: i18n-paritet, provenans + eval, copy-skanning, fabricerade-minnes-eval
This commit is contained in:
@@ -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<string, Record<string, unknown>> = {
|
||||||
|
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, unknown>): string | undefined {
|
||||||
|
const flat = catalog["onboarding.memoryTransparency"];
|
||||||
|
if (typeof flat === "string") return flat;
|
||||||
|
const nested = catalog["onboarding"] as Record<string, unknown> | 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<string>();
|
||||||
|
for (const [lang, catalog] of Object.entries(LOCALES)) {
|
||||||
|
const str = getTransparency(catalog) ?? "";
|
||||||
|
seen.add(str);
|
||||||
|
}
|
||||||
|
expect(seen.size).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { AamosTaskType, TaskInput, TaskOutput } from "@app/ai-contracts";
|
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";
|
import { numbersPreserved } from "../processors/translation.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +18,7 @@ export interface EvalCheck {
|
|||||||
passed: boolean;
|
passed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvalCase<T extends AamosTaskType = AamosTaskType> {
|
export interface AamosEvalCase<T extends AamosTaskType = AamosTaskType> {
|
||||||
id: string;
|
id: string;
|
||||||
taskType: T;
|
taskType: T;
|
||||||
descriptionSv: string;
|
descriptionSv: string;
|
||||||
@@ -25,6 +27,19 @@ export interface EvalCase<T extends AamosTaskType = AamosTaskType> {
|
|||||||
verify(output: TaskOutput<T>): EvalCheck[];
|
verify(output: TaskOutput<T>): 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 });
|
const check = (name: string, passed: boolean): EvalCheck => ({ name, passed });
|
||||||
|
|
||||||
function confidenceInRange(values: number[]): boolean {
|
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)),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ for (const candidate of [".env", "../.env", "../../.env"]) {
|
|||||||
|
|
||||||
import { schema } from "@app/database";
|
import { schema } from "@app/database";
|
||||||
import { createContext } from "../context.js";
|
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).
|
* Kör AAMOS-utvärderingssviten (spec §40).
|
||||||
@@ -36,6 +36,9 @@ async function main() {
|
|||||||
let checks: { name: string; passed: boolean }[] = [];
|
let checks: { name: string; passed: boolean }[] = [];
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
if (isDeterministicEvalCase(evalCase)) {
|
||||||
|
checks = evalCase.run();
|
||||||
|
} else {
|
||||||
const result = await ctx.aamos.runTask(evalCase.taskType, evalCase.input as never);
|
const result = await ctx.aamos.runTask(evalCase.taskType, evalCase.input as never);
|
||||||
if (result.status !== "ok" || !result.output) {
|
if (result.status !== "ok" || !result.output) {
|
||||||
error = `status=${result.status}`;
|
error = `status=${result.status}`;
|
||||||
@@ -46,21 +49,23 @@ async function main() {
|
|||||||
...evalCase.verify(result.output as never),
|
...evalCase.verify(result.output as never),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = (err as Error).message;
|
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 ms = Date.now() - started;
|
||||||
const passed = checks.every((c) => c.passed);
|
const passed = checks.every((c) => c.passed);
|
||||||
totalChecks += checks.length;
|
totalChecks += checks.length;
|
||||||
failedChecks += checks.filter((c) => !c.passed).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}`);
|
for (const c of checks) if (!c.passed) console.log(` ✗ ${c.name}`);
|
||||||
if (error) console.log(` fel: ${error}`);
|
if (error) console.log(` fel: ${error}`);
|
||||||
|
|
||||||
await ctx.db.insert(schema.aiEvalRuns).values({
|
await ctx.db.insert(schema.aiEvalRuns).values({
|
||||||
taskType: evalCase.taskType,
|
taskType: isDeterministicEvalCase(evalCase) ? "DETERMINISTIC" : evalCase.taskType,
|
||||||
modelVersion: `aamos-${mode}`,
|
modelVersion: `aamos-${mode}`,
|
||||||
promptVersion: "eval-suite-v1",
|
promptVersion: "eval-suite-v1",
|
||||||
metrics: {
|
metrics: {
|
||||||
|
|||||||
@@ -97,8 +97,18 @@ export async function deriveMemoryUpdates(
|
|||||||
return { proposals: [], usage };
|
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 {
|
return {
|
||||||
proposals: parsed.data.memoryUpdates.map((u) => ({
|
proposals: groundedProposals.map((u) => ({
|
||||||
key: u.key,
|
key: u.key,
|
||||||
kind: u.kind,
|
kind: u.kind,
|
||||||
summarySv: u.summarySv,
|
summarySv: u.summarySv,
|
||||||
@@ -295,6 +305,448 @@ const UI_LABELS: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MemoryValueRenderer = (value: Record<string, unknown>) => string | null;
|
||||||
|
|
||||||
|
const MEMORY_SUMMARY_TEMPLATES: Record<
|
||||||
|
string,
|
||||||
|
Record<string, MemoryValueRenderer>
|
||||||
|
> = {
|
||||||
|
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).
|
* 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
|
* Kända value-former renderas per språk; okända faller tillbaka på summarySv
|
||||||
@@ -304,41 +756,55 @@ export function renderMemorySummary(
|
|||||||
item: Pick<MemoryItem, "summarySv" | "value">,
|
item: Pick<MemoryItem, "summarySv" | "value">,
|
||||||
languageTag: string,
|
languageTag: string,
|
||||||
): string {
|
): string {
|
||||||
const lang = languageTag.split("-")[0] ?? "sv";
|
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
||||||
if (lang === "sv") return item.summarySv;
|
const catalog = MEMORY_SUMMARY_TEMPLATES[lang] ?? MEMORY_SUMMARY_TEMPLATES.sv!;
|
||||||
const v = item.value as Record<string, unknown> | null | undefined;
|
const v = item.value as Record<string, unknown> | null | undefined;
|
||||||
if (v && typeof v === "object") {
|
if (v && typeof v === "object") {
|
||||||
if (typeof v.favoriteCuisine === "string") {
|
if (typeof v.favoriteCuisine === "string" && catalog.favoriteCuisine) {
|
||||||
return `Favorite cuisine: ${String(v.favoriteCuisine)}`;
|
const rendered = catalog.favoriteCuisine(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.dislikedIngredient === "string") {
|
if (typeof v.avoidIngredientId === "string" && catalog.avoidIngredient) {
|
||||||
return `Avoids: ${String(v.dislikedIngredient)}`;
|
const rendered = catalog.avoidIngredient(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.typicalPortions === "number") {
|
if (typeof v.goal === "string" && catalog.goal) {
|
||||||
return `Usually cooks ${v.typicalPortions} servings`;
|
const rendered = catalog.goal(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.spicePreference === "string") {
|
if (typeof v.primaryGoal === "string" && catalog.primaryGoal) {
|
||||||
return `Spice preference: ${String(v.spicePreference)}`;
|
const rendered = catalog.primaryGoal(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.goal === "string") {
|
if (typeof v.allergen === "string" && catalog.allergen) {
|
||||||
return `Goal: ${String(v.goal)}`;
|
const rendered = catalog.allergen(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.primaryGoal === "string") {
|
if (typeof v.spiceLevelMax === "number" && catalog.spiceLevelMax) {
|
||||||
return `Primary goal: ${String(v.primaryGoal)}`;
|
const rendered = catalog.spiceLevelMax(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.allergen === "string") {
|
if (typeof v.dislikedIngredient === "string" && catalog.dislikedIngredient) {
|
||||||
return `Allergy: ${String(v.allergen)}`;
|
const rendered = catalog.dislikedIngredient(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.avoidIngredientId === "string") {
|
if (typeof v.typicalPortions === "number" && catalog.typicalPortions) {
|
||||||
return `Avoids ingredient: ${String(v.avoidIngredientId)}`;
|
const rendered = catalog.typicalPortions(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
if (typeof v.spiceLevelMax === "number") {
|
if (typeof v.spicePreference === "string" && catalog.spicePreference) {
|
||||||
return `Max spice level: ${v.spiceLevelMax}`;
|
const rendered = catalog.spicePreference(v);
|
||||||
|
if (rendered) return rendered;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return item.summarySv;
|
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 {
|
export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): MemoryOverview {
|
||||||
const lang = languageTag.split("-")[0] ?? "sv";
|
const lang = languageTag.split("-")[0] ?? "sv";
|
||||||
const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!;
|
const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { MemoryItem } from "@app/shared-types";
|
||||||
|
|
||||||
function makeItem(overrides: Partial<MemoryItem> = {}): MemoryItem {
|
function makeItem(overrides: Partial<MemoryItem> = {}): MemoryItem {
|
||||||
@@ -79,4 +81,86 @@ describe("buildMemoryOverview", () => {
|
|||||||
expect(item.pausedLabel).toBe("Paused");
|
expect(item.pausedLabel).toBe("Paused");
|
||||||
expect(item.summary).toBe("Favorite cuisine: swedish");
|
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<T extends AamosTaskType>(): Promise<AamosResult<T>> {
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
output: output as AamosResult<T>["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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,30 +5,272 @@ import type {
|
|||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { renderProvenance } from "./provenance-templates.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<LanguageTag, WhyTemplates> = {
|
||||||
|
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, string | number>): string {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(vars[key] ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Varför rekommenderar vi detta?" (spec §18, S1).
|
* "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.).
|
* 1. Grundade delpoäng (täckning, utgångsdatum, etc.).
|
||||||
* 2. Personaliserings-mallar från provenans (endast om samtycke granted).
|
* 2. Personaliserings-mallar från provenans (endast om samtycke granted).
|
||||||
*
|
*
|
||||||
* Alla användarvisningstexter kommer från mallar, inte fri AI-text.
|
* Alla användarvisningstexter kommer från mallar, inte fri AI-text.
|
||||||
* R7: näringsrelaterad copy är positiv/stödjande, aldrig restriktiv.
|
* R7: näringsrelaterad copy är positiv/stödjande, aldrig restriktiv.
|
||||||
*/
|
*/
|
||||||
export function buildWhySv(
|
export function buildWhy(
|
||||||
candidate: RecommendationCandidate,
|
candidate: RecommendationCandidate,
|
||||||
ctx: RecommendationContext,
|
ctx: RecommendationContext,
|
||||||
parts: Record<string, number>,
|
parts: Record<string, number>,
|
||||||
provenance: ProvenanceEntry[] = [],
|
provenance: ProvenanceEntry[] = [],
|
||||||
|
languageTag = "sv-SE",
|
||||||
): string {
|
): string {
|
||||||
|
const t = getTemplates(languageTag);
|
||||||
const sentences: string[] = [];
|
const sentences: string[] = [];
|
||||||
|
|
||||||
const pct = Math.round(candidate.coverage.coverage * 100);
|
const pct = Math.round(candidate.coverage.coverage * 100);
|
||||||
if (pct >= 100) {
|
if (pct >= 100) {
|
||||||
sentences.push("Ni har alla ingredienser hemma.");
|
sentences.push(t.coverage100);
|
||||||
} else if (pct >= 60) {
|
} else if (pct >= 60) {
|
||||||
sentences.push(`Ni har ${pct} % av ingredienserna hemma.`);
|
sentences.push(render(t.coveragePct, { pct }));
|
||||||
} else if (pct > 0) {
|
} 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
|
const urgent = candidate.coverage.expiringUsed
|
||||||
@@ -36,59 +278,68 @@ export function buildWhySv(
|
|||||||
.sort((a, b) => (a.mostUrgentDaysLeft ?? 99) - (b.mostUrgentDaysLeft ?? 99))[0];
|
.sort((a, b) => (a.mostUrgentDaysLeft ?? 99) - (b.mostUrgentDaysLeft ?? 99))[0];
|
||||||
if (urgent) {
|
if (urgent) {
|
||||||
const days = urgent.mostUrgentDaysLeft ?? 0;
|
const days = urgent.mostUrgentDaysLeft ?? 0;
|
||||||
const when = days <= 0 ? "i dag" : days === 1 ? "senast i morgon" : `inom ${days} dagar`;
|
const tpl = days <= 0 ? t.expiringToday : days === 1 ? t.expiringTomorrow : t.expiringDays;
|
||||||
sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`);
|
sentences.push(render(tpl, { ingredient: capitalize(urgent.displayNameSv), days }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// R7: positiv näringsframing
|
// R7: positiv näringsframing
|
||||||
const protein = Math.round(candidate.nutritionPerPortion.proteinG);
|
const protein = Math.round(candidate.nutritionPerPortion.proteinG);
|
||||||
if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) {
|
if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) {
|
||||||
sentences.push(`Rätten ger ${protein} gram protein per portion.`);
|
sentences.push(render(t.protein, { protein }));
|
||||||
}
|
}
|
||||||
if (ctx.isTrainingDay && protein >= 35) {
|
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) {
|
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) {
|
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) {
|
} 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) {
|
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) {
|
if ((parts.budget ?? 0) >= 1 && candidate.estimatedCostMinorPerPortion != null) {
|
||||||
sentences.push(
|
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) {
|
if ((parts.craving ?? 0) >= 1) {
|
||||||
sentences.push("Matchar det du är sugen på.");
|
sentences.push(t.craving);
|
||||||
}
|
}
|
||||||
|
|
||||||
// S1: personaliserings-mallar från provenans
|
// S1: personaliserings-mallar från provenans
|
||||||
if (ctx.personalizationEnabled && provenance.length > 0) {
|
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), languageTag); // max 2 personliga satser
|
||||||
const rendered = renderProvenance(provenance.slice(0, 2), lang); // max 2 personliga satser
|
|
||||||
if (rendered) {
|
if (rendered) {
|
||||||
sentences.push(rendered);
|
sentences.push(rendered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sentences.length === 0) {
|
if (sentences.length === 0) {
|
||||||
sentences.push("En balanserad rätt som passar er profil.");
|
sentences.push(t.fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sentences.join(" ");
|
return sentences.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bakåtkompatibel wrapper: svensk whySv. */
|
||||||
|
export function buildWhySv(
|
||||||
|
candidate: RecommendationCandidate,
|
||||||
|
ctx: RecommendationContext,
|
||||||
|
parts: Record<string, number>,
|
||||||
|
provenance: ProvenanceEntry[] = [],
|
||||||
|
): string {
|
||||||
|
return buildWhy(candidate, ctx, parts, provenance, "sv-SE");
|
||||||
|
}
|
||||||
|
|
||||||
function capitalize(s: string): string {
|
function capitalize(s: string): string {
|
||||||
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
|
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ export function renderProvenanceList(
|
|||||||
.filter((s): s is string => s != null);
|
.filter((s): s is string => s != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Språk som varje proveniensmall är översatt till. */
|
||||||
|
export function provenanceTemplateLanguages(): Record<string, LanguageTag[]> {
|
||||||
|
const result: Record<string, LanguageTag[]> = {};
|
||||||
|
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). */
|
/** Förbjudna ord/fraser i proveniens/copy — R3 (ingen skam) + R7 (välmående). */
|
||||||
export const FORBIDDEN_COPY_PATTERNS = [
|
export const FORBIDDEN_COPY_PATTERNS = [
|
||||||
/överskrid/i,
|
/överskrid/i,
|
||||||
|
|||||||
@@ -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> = {}): 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user