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 }; } return { proposals: parsed.data.memoryUpdates.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.", }, }; /** * 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"; if (lang === "sv") return item.summarySv; const v = item.value as Record | null | undefined; if (v && typeof v === "object") { if (typeof v.favoriteCuisine === "string") { return `Favorite cuisine: ${String(v.favoriteCuisine)}`; } if (typeof v.dislikedIngredient === "string") { return `Avoids: ${String(v.dislikedIngredient)}`; } if (typeof v.typicalPortions === "number") { return `Usually cooks ${v.typicalPortions} servings`; } if (typeof v.spicePreference === "string") { return `Spice preference: ${String(v.spicePreference)}`; } if (typeof v.goal === "string") { return `Goal: ${String(v.goal)}`; } if (typeof v.primaryGoal === "string") { return `Primary goal: ${String(v.primaryGoal)}`; } if (typeof v.allergen === "string") { return `Allergy: ${String(v.allergen)}`; } if (typeof v.avoidIngredientId === "string") { return `Avoids ingredient: ${String(v.avoidIngredientId)}`; } if (typeof v.spiceLevelMax === "number") { return `Max spice level: ${v.spiceLevelMax}`; } } return item.summarySv; } 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)}`; }