Files
Cibello-app/packages/memory-client/src/index.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

727 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 §3233);
* 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 0100 %. */
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)}`;
}