727 lines
28 KiB
TypeScript
727 lines
28 KiB
TypeScript
import { TASK_CONTRACTS, type AamosClient } from "@app/ai-contracts";
|
||
import type { MemoryItem, MemoryKind, SignalOrigin } from "@app/shared-types";
|
||
|
||
/**
|
||
* Minnesarkitektur (spec §32):
|
||
*
|
||
* - plattformens databas (memory_items) är den ANVÄNDARSYNLIGA sanningen:
|
||
* allt som visas i "Vad plattformen vet om mig" och allt som kan korrigeras,
|
||
* pausas, raderas och exporteras finns där.
|
||
* - AAMOS får härleda nya minnesposter (UPDATE_USER_MEMORY-jobbet), men
|
||
* förslagen skrivs alltid in i plattformens tabell där användaren äger dem.
|
||
* - Personligt minne är ALDRIG automatiskt träningsdata (spec §32–33);
|
||
* consentSnapshot följer med varje härledning.
|
||
*/
|
||
|
||
export interface MemoryUpdateProposal {
|
||
key: string;
|
||
kind: MemoryKind;
|
||
summarySv: string;
|
||
value: unknown;
|
||
origin: Exclude<SignalOrigin, "user_stated">;
|
||
confidence: number;
|
||
expiresAt?: string | null;
|
||
/** Händelse-ID:n som stödjer förslaget (R1: grundat, aldrig påhittat). */
|
||
sourceEventIds: string[];
|
||
}
|
||
|
||
export interface MemorySyncInput {
|
||
scope: "user" | "household";
|
||
scopeId: string;
|
||
events: Array<{ id: string; type: string; occurredAt: string; payload: unknown }>;
|
||
existingMemoryKeys: string[];
|
||
consentFlags: {
|
||
personalization: boolean;
|
||
anonymizedImprovement: boolean;
|
||
imageTraining: boolean;
|
||
};
|
||
correlationId?: string;
|
||
localeContext?: import("@app/shared-types").LocaleContext;
|
||
}
|
||
|
||
/**
|
||
* Kör minnesuppdatering via AAMOS. Returnerar granskade förslag + faktisk
|
||
* token/kostnadsanvändning så att workern kan bokföra i ai_usage_counters.
|
||
* Utan personaliseringssamtycke körs ingenting.
|
||
*
|
||
* R1 (grundat, aldrig påhittat): AAMOS får explicit instruktion om att aldrig
|
||
* fabricera minnen; svaret valideras mot Zod-kontraktet.
|
||
*/
|
||
export async function deriveMemoryUpdates(
|
||
aamos: AamosClient,
|
||
input: MemorySyncInput,
|
||
): Promise<{
|
||
proposals: MemoryUpdateProposal[];
|
||
usage: { costUsd: number; inputTokens: number; outputTokens: number } | null;
|
||
}> {
|
||
if (!input.consentFlags.personalization) {
|
||
return { proposals: [], usage: null };
|
||
}
|
||
|
||
const result = await aamos.runTask(
|
||
"UPDATE_USER_MEMORY",
|
||
{
|
||
scope: input.scope,
|
||
scopeId: input.scopeId,
|
||
events: input.events,
|
||
existingMemoryKeys: input.existingMemoryKeys,
|
||
},
|
||
{
|
||
subjectRef: pseudonymize(input.scopeId),
|
||
consentFlags: input.consentFlags,
|
||
systemInstruction:
|
||
"Du får ENDAST sammanfatta faktiska events. Hitta ALDRIG på minnen, preferenser eller mönster som inte har explicit stöd i events. Varje förslag måste referera till ett eller flera events via sourceEventIds. ai_inferred-förslag ska ha låg konfidens.",
|
||
...(input.localeContext ? { localeContext: input.localeContext } : {}),
|
||
...(input.correlationId ? { correlationId: input.correlationId } : {}),
|
||
},
|
||
);
|
||
|
||
const usage =
|
||
result.status === "ok" && result.output
|
||
? {
|
||
costUsd: result.costUsd ?? 0,
|
||
inputTokens: result.inputTokens ?? 0,
|
||
outputTokens: result.outputTokens ?? 0,
|
||
}
|
||
: null;
|
||
|
||
if (result.status !== "ok" || !result.output) {
|
||
return { proposals: [], usage };
|
||
}
|
||
|
||
// Extra Zod-validering: ett brutet kontrakt ska aldrig nå användardata.
|
||
const parsed = TASK_CONTRACTS["UPDATE_USER_MEMORY"].output.safeParse(result.output);
|
||
if (!parsed.success) {
|
||
// eslint-disable-next-line no-console
|
||
console.error("UPDATE_USER_MEMORY output bröt mot kontraktet:", parsed.error.message);
|
||
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: groundedProposals.map((u) => ({
|
||
key: u.key,
|
||
kind: u.kind,
|
||
summarySv: u.summarySv,
|
||
value: u.value,
|
||
origin: u.origin,
|
||
confidence: u.confidence,
|
||
expiresAt: u.expiresAt,
|
||
sourceEventIds: u.sourceEventIds,
|
||
})),
|
||
usage,
|
||
};
|
||
}
|
||
|
||
export interface MemoryOverviewItem extends MemoryItem {
|
||
summary: string;
|
||
/** Lokaliserad etikett för ursprung (user_stated/observed/ai_inferred). */
|
||
originLabel: string;
|
||
/** Konfidens 0–100 %. */
|
||
confidencePercent: number;
|
||
/** Synlig etikett om posten är pausad. */
|
||
pausedLabel?: string;
|
||
/** Tydlig gissningsmarkering för ai_inferred (R1). */
|
||
guessLabel?: string;
|
||
}
|
||
|
||
/** Gruppera minnesposter för "Vad plattformen vet om mig"-vyn. */
|
||
export interface MemoryOverview {
|
||
language: string;
|
||
sections: Array<{
|
||
kind: MemoryKind;
|
||
/** Rubrik på begärt språk (i18n M10). titleSv behålls för bakåtkompatibilitet. */
|
||
title: string;
|
||
titleSv: string;
|
||
items: MemoryOverviewItem[];
|
||
}>;
|
||
totalCount: number;
|
||
pausedCount: number;
|
||
}
|
||
|
||
const KIND_TITLES: Record<string, Record<MemoryKind, string>> = {
|
||
sv: {
|
||
structured_fact: "Fakta om dig och hushållet",
|
||
event: "Händelser vi kommer ihåg",
|
||
semantic: "Mönster vi har observerat",
|
||
profile_summary: "Din profil i korthet",
|
||
recipe_memory: "Recept och måltider",
|
||
},
|
||
en: {
|
||
structured_fact: "Facts about you and your household",
|
||
event: "Events we remember",
|
||
semantic: "Patterns we've observed",
|
||
profile_summary: "Your profile at a glance",
|
||
recipe_memory: "Recipes and meals",
|
||
},
|
||
es: {
|
||
structured_fact: "Datos sobre ti y tu hogar",
|
||
event: "Eventos que recordamos",
|
||
semantic: "Patrones observados",
|
||
profile_summary: "Tu perfil de un vistazo",
|
||
recipe_memory: "Recetas y comidas",
|
||
},
|
||
it: {
|
||
structured_fact: "Informazioni su di te e la tua famiglia",
|
||
event: "Eventi che ricordiamo",
|
||
semantic: "Schemi osservati",
|
||
profile_summary: "Il tuo profilo in breve",
|
||
recipe_memory: "Ricette e pasti",
|
||
},
|
||
de: {
|
||
structured_fact: "Fakten über dich und deinen Haushalt",
|
||
event: "Ereignisse, die wir uns merken",
|
||
semantic: "Beobachtete Muster",
|
||
profile_summary: "Dein Profil auf einen Blick",
|
||
recipe_memory: "Rezepte und Mahlzeiten",
|
||
},
|
||
fr: {
|
||
structured_fact: "Infos sur vous et votre foyer",
|
||
event: "Événements mémorisés",
|
||
semantic: "Habitudes observées",
|
||
profile_summary: "Votre profil en un coup d'œil",
|
||
recipe_memory: "Recettes et repas",
|
||
},
|
||
da: {
|
||
structured_fact: "Fakta om dig og husstanden",
|
||
event: "Hændelser vi husker",
|
||
semantic: "Mønstre vi har observeret",
|
||
profile_summary: "Din profil i korte træk",
|
||
recipe_memory: "Opskrifter og måltider",
|
||
},
|
||
nb: {
|
||
structured_fact: "Fakta om deg og husstanden",
|
||
event: "Hendelser vi husker",
|
||
semantic: "Mønstre vi har observert",
|
||
profile_summary: "Profilen din i korte trekk",
|
||
recipe_memory: "Oppskrifter og måltider",
|
||
},
|
||
fi: {
|
||
structured_fact: "Tietoa sinusta ja kotitaloudestasi",
|
||
event: "Muistamamme tapahtumat",
|
||
semantic: "Havaitut tavat",
|
||
profile_summary: "Profiilisi lyhyesti",
|
||
recipe_memory: "Reseptit ja ateriat",
|
||
},
|
||
nl: {
|
||
structured_fact: "Feiten over jou en je huishouden",
|
||
event: "Gebeurtenissen die we onthouden",
|
||
semantic: "Waargenomen patronen",
|
||
profile_summary: "Je profiel in het kort",
|
||
recipe_memory: "Recepten en maaltijden",
|
||
},
|
||
pl: {
|
||
structured_fact: "Fakty o Tobie i Twoim gospodarstwie",
|
||
event: "Zdarzenia, które pamiętamy",
|
||
semantic: "Zaobserwowane wzorce",
|
||
profile_summary: "Twój profil w skrócie",
|
||
recipe_memory: "Przepisy i posiłki",
|
||
},
|
||
pt: {
|
||
structured_fact: "Factos sobre si e o seu agregado",
|
||
event: "Eventos que recordamos",
|
||
semantic: "Padrões observados",
|
||
profile_summary: "O seu perfil em resumo",
|
||
recipe_memory: "Receitas e refeições",
|
||
},
|
||
};
|
||
|
||
const UI_LABELS: Record<
|
||
string,
|
||
{
|
||
origin: Record<SignalOrigin, string>;
|
||
paused: string;
|
||
guess: string;
|
||
}
|
||
> = {
|
||
sv: {
|
||
origin: { user_stated: "Du har sagt", observed: "Vi har sett", ai_inferred: "Gissning" },
|
||
paused: "Pausad",
|
||
guess: "Detta är en gissning – bekräfta eller ändra om det stämmer.",
|
||
},
|
||
en: {
|
||
origin: { user_stated: "You said", observed: "We noticed", ai_inferred: "Guess" },
|
||
paused: "Paused",
|
||
guess: "This is a guess — confirm or change it if it fits.",
|
||
},
|
||
es: {
|
||
origin: { user_stated: "Dijiste", observed: "Notamos", ai_inferred: "Suposición" },
|
||
paused: "Pausado",
|
||
guess: "Esto es una suposición: confírmala o cámbiala si encaja.",
|
||
},
|
||
it: {
|
||
origin: { user_stated: "Hai detto", observed: "Abbiamo notato", ai_inferred: "Ipotesi" },
|
||
paused: "In pausa",
|
||
guess: "Questa è un'ipotesi: confermala o modificala se è corretta.",
|
||
},
|
||
de: {
|
||
origin: {
|
||
user_stated: "Du hast gesagt",
|
||
observed: "Wir haben bemerkt",
|
||
ai_inferred: "Vermutung",
|
||
},
|
||
paused: "Pausiert",
|
||
guess: "Dies ist eine Vermutung: bestätige oder ändere sie, wenn sie stimmt.",
|
||
},
|
||
fr: {
|
||
origin: {
|
||
user_stated: "Vous avez dit",
|
||
observed: "Nous avons remarqué",
|
||
ai_inferred: "Hypothèse",
|
||
},
|
||
paused: "En pause",
|
||
guess: "Ceci est une hypothèse : confirmez ou modifiez si cela vous convient.",
|
||
},
|
||
da: {
|
||
origin: { user_stated: "Du har sagt", observed: "Vi har set", ai_inferred: "Gæt" },
|
||
paused: "Pauset",
|
||
guess: "Dette er et gæt — bekræft eller ændr det, hvis det passer.",
|
||
},
|
||
nb: {
|
||
origin: { user_stated: "Du har sagt", observed: "Vi har sett", ai_inferred: "Gjettning" },
|
||
paused: "Pauset",
|
||
guess: "Dette er en gjetning — bekreft eller endre hvis det stemmer.",
|
||
},
|
||
fi: {
|
||
origin: { user_stated: "Olet sanonut", observed: "Olemme huomanneet", ai_inferred: "Arvaus" },
|
||
paused: "Tauolla",
|
||
guess: "Tämä on arvaus: vahvista tai muuta se, jos se sopii.",
|
||
},
|
||
nl: {
|
||
origin: { user_stated: "Jij zei", observed: "We hebben gezien", ai_inferred: "Gok" },
|
||
paused: "Gepauzeerd",
|
||
guess: "Dit is een gok: bevestig of pas aan als het klopt.",
|
||
},
|
||
pl: {
|
||
origin: { user_stated: "Powiedziałeś", observed: "Zauważyliśmy", ai_inferred: "Domysł" },
|
||
paused: "Wstrzymane",
|
||
guess: "To jest przypuszczenie — potwierdź lub zmień, jeśli się zgadza.",
|
||
},
|
||
pt: {
|
||
origin: { user_stated: "Disseste", observed: "Reparámos", ai_inferred: "Suposição" },
|
||
paused: "Pausado",
|
||
guess: "Isto é uma suposição: confirma ou altera se fizer sentido.",
|
||
},
|
||
};
|
||
|
||
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).
|
||
* Kända value-former renderas per språk; okända faller tillbaka på summarySv
|
||
* (nya minnen får lokaliserad summary direkt från AAMOS via localeContext).
|
||
*/
|
||
export function renderMemorySummary(
|
||
item: Pick<MemoryItem, "summarySv" | "value">,
|
||
languageTag: string,
|
||
): string {
|
||
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
||
const catalog = MEMORY_SUMMARY_TEMPLATES[lang] ?? MEMORY_SUMMARY_TEMPLATES.sv!;
|
||
const v = item.value as Record<string, unknown> | null | undefined;
|
||
if (v && typeof v === "object") {
|
||
if (typeof v.favoriteCuisine === "string" && catalog.favoriteCuisine) {
|
||
const rendered = catalog.favoriteCuisine(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.avoidIngredientId === "string" && catalog.avoidIngredient) {
|
||
const rendered = catalog.avoidIngredient(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.goal === "string" && catalog.goal) {
|
||
const rendered = catalog.goal(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.primaryGoal === "string" && catalog.primaryGoal) {
|
||
const rendered = catalog.primaryGoal(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.allergen === "string" && catalog.allergen) {
|
||
const rendered = catalog.allergen(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.spiceLevelMax === "number" && catalog.spiceLevelMax) {
|
||
const rendered = catalog.spiceLevelMax(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.dislikedIngredient === "string" && catalog.dislikedIngredient) {
|
||
const rendered = catalog.dislikedIngredient(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
if (typeof v.typicalPortions === "number" && catalog.typicalPortions) {
|
||
const rendered = catalog.typicalPortions(v);
|
||
if (rendered) return rendered;
|
||
}
|
||
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!;
|
||
const labels = UI_LABELS[lang] ?? UI_LABELS.sv!;
|
||
const byKind = new Map<MemoryKind, MemoryItem[]>();
|
||
for (const item of items) {
|
||
const list = byKind.get(item.kind) ?? [];
|
||
list.push(item);
|
||
byKind.set(item.kind, list);
|
||
}
|
||
const sections = [...byKind.entries()].map(([kind, list]) => ({
|
||
kind,
|
||
title: titles[kind],
|
||
titleSv: KIND_TITLES.sv![kind],
|
||
items: list
|
||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||
.map((i) => {
|
||
const overviewItem: MemoryOverviewItem = {
|
||
...i,
|
||
summary: renderMemorySummary(i, languageTag),
|
||
originLabel: labels.origin[i.origin] ?? labels.origin.ai_inferred,
|
||
confidencePercent: Math.round(i.confidence * 100),
|
||
};
|
||
if (i.paused) {
|
||
overviewItem.pausedLabel = labels.paused;
|
||
}
|
||
if (i.origin === "ai_inferred") {
|
||
overviewItem.guessLabel = labels.guess;
|
||
}
|
||
return overviewItem;
|
||
}),
|
||
}));
|
||
return {
|
||
language: lang,
|
||
sections,
|
||
totalCount: items.length,
|
||
pausedCount: items.filter((i) => i.paused).length,
|
||
};
|
||
}
|
||
|
||
/** Pseudonymisera id innan det skickas till AAMOS (spec §56: minimization). */
|
||
export function pseudonymize(id: string): string {
|
||
// Enkel stabil pseudonym – riktiga miljöer använder HMAC med rotationsbar nyckel.
|
||
return `subj_${Buffer.from(id).toString("base64url").slice(0, 16)}`;
|
||
}
|