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

387 lines
13 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 };
}
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 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.",
},
};
/**
* 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)}`;
}
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<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)}`;
}