Files
Cibello-app/packages/memory-client/src/index.ts
T
2026-08-05 19:21:11 +07:00

239 lines
7.8 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 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;
}
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)}`;
}