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; 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> = { 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; 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 | null; const MEMORY_SUMMARY_TEMPLATES: Record> = { sv: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Favoritkök: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Undviker ingrediens: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Primärt mål: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergi: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Max styrka: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Undviker: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Laggar vanligen ${v.typicalPortions} portioner` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Styrkepreferens: ${String(v.spicePreference)}` : null, }, en: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Favorite cuisine: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Avoids ingredient: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Goal: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Primary goal: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergy: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Max spice level: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Avoids: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Usually cooks ${v.typicalPortions} servings` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Spice preference: ${String(v.spicePreference)}` : null, }, es: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Cocina favorita: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Evita ingrediente: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Objetivo principal: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Alergia: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Nivel máximo de picante: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Evita: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Suele cocinar ${v.typicalPortions} raciones` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Preferencia de picante: ${String(v.spicePreference)}` : null, }, it: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Cucina preferita: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Evita ingrediente: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Obiettivo: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Obiettivo principale: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergia: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Livello piccante max: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Evita: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Di solito cucina ${v.typicalPortions} porzioni` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Preferenza piccante: ${String(v.spicePreference)}` : null, }, de: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Lieblingsküche: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Vermeidet Zutat: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Ziel: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Hauptziel: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergie: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Max. Schärfe: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Vermeidet: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Kocht meist ${v.typicalPortions} Portionen` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Schärfepräferenz: ${String(v.spicePreference)}` : null, }, fr: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Cuisine préférée: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Évite l'ingrédient: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Objectif: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Objectif principal: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergie: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Niveau épicé max: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Évite: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Cuisine habituellement ${v.typicalPortions} portions` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Préférence épicée: ${String(v.spicePreference)}` : null, }, da: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Yndlingskøkken: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Undgår ingrediens: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Primært mål: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergi: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Max styrke: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Undgår: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Tilbereder som regel ${v.typicalPortions} portioner` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Styrkepræference: ${String(v.spicePreference)}` : null, }, nb: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Favorittkjøkken: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Unngår ingrediens: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Primært mål: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergi: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Maks styrke: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Unngår: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Lager vanligvis ${v.typicalPortions} porsjoner` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Styrkepreferanse: ${String(v.spicePreference)}` : null, }, fi: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Suosikkikeittiö: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Vältettävä ainesosa: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Tavoite: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Päätavoite: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergia: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Max tulisuus: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Vältää: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Valmistaa yleensä ${v.typicalPortions} annosta` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Tulisuusmieltymys: ${String(v.spicePreference)}` : null, }, nl: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Favoriete keuken: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Vermijdt ingrediënt: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Doel: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Primair doel: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Allergie: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Max pittigheid: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Vermijdt: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Kookt meestal ${v.typicalPortions} porties` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Pittigheidspreferentie: ${String(v.spicePreference)}` : null, }, pl: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Ulubiona kuchnia: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Unika składnika: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Cel: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Cel główny: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Alergia: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Maks. ostrość: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Unika: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Zwykle gotuje ${v.typicalPortions} porcje` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Preferencja ostrości: ${String(v.spicePreference)}` : null, }, pt: { favoriteCuisine: (v) => typeof v.favoriteCuisine === "string" ? `Cozinha favorita: ${String(v.favoriteCuisine)}` : null, avoidIngredient: (v) => typeof v.avoidIngredientId === "string" ? `Evita ingrediente: ${String(v.avoidIngredientId)}` : null, goal: (v) => (typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null), primaryGoal: (v) => typeof v.primaryGoal === "string" ? `Objetivo principal: ${String(v.primaryGoal)}` : null, allergen: (v) => (typeof v.allergen === "string" ? `Alergia: ${String(v.allergen)}` : null), spiceLevelMax: (v) => typeof v.spiceLevelMax === "number" ? `Nível picante máx.: ${v.spiceLevelMax}` : null, dislikedIngredient: (v) => typeof v.dislikedIngredient === "string" ? `Evita: ${String(v.dislikedIngredient)}` : null, typicalPortions: (v) => typeof v.typicalPortions === "number" ? `Costuma cozinhar ${v.typicalPortions} porções` : null, spicePreference: (v) => typeof v.spicePreference === "string" ? `Preferência picante: ${String(v.spicePreference)}` : null, }, }; const SUPPORTED_MEMORY_LANGS = Object.keys(MEMORY_SUMMARY_TEMPLATES); /** * Deterministisk rendering av summary ur strukturerad value (i18n-spec §23, M10). * Kända value-former renderas per språk; okända faller tillbaka på summarySv * (nya minnen får lokaliserad summary direkt från AAMOS via localeContext). */ export function renderMemorySummary( item: Pick, 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 | 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(); 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)}`; }