239 lines
7.8 KiB
TypeScript
239 lines
7.8 KiB
TypeScript
import 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;
|
||
}
|
||
|
||
export interface MemorySyncInput {
|
||
scope: "user" | "household";
|
||
scopeId: string;
|
||
events: Array<{ 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 förslag – anroparen (workern)
|
||
* persisterar dem i memory_items och publicerar MEMORY_UPDATED-event.
|
||
* Utan personaliseringssamtycke körs ingenting.
|
||
*/
|
||
export async function deriveMemoryUpdates(
|
||
aamos: AamosClient,
|
||
input: MemorySyncInput,
|
||
): Promise<MemoryUpdateProposal[]> {
|
||
if (!input.consentFlags.personalization) return [];
|
||
|
||
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,
|
||
...(input.localeContext ? { localeContext: input.localeContext } : {}),
|
||
...(input.correlationId ? { correlationId: input.correlationId } : {}),
|
||
},
|
||
);
|
||
|
||
if (result.status !== "ok" || !result.output) return [];
|
||
return result.output.memoryUpdates.map((u) => ({
|
||
key: u.key,
|
||
kind: u.kind,
|
||
summarySv: u.summarySv,
|
||
value: u.value,
|
||
origin: u.origin,
|
||
confidence: u.confidence,
|
||
expiresAt: u.expiresAt,
|
||
}));
|
||
}
|
||
|
||
/** 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: Array<MemoryItem & { summary: string }>;
|
||
}>;
|
||
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",
|
||
},
|
||
};
|
||
|
||
/**
|
||
* 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";
|
||
if (lang === "sv") return item.summarySv;
|
||
const v = item.value as Record<string, unknown> | 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)}`;
|
||
}
|
||
}
|
||
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 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) => ({ ...i, summary: renderMemorySummary(i, languageTag) })),
|
||
}));
|
||
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)}`;
|
||
}
|