From 05bba93b90b84d61223b8afe529fb0fa4317b952 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Tue, 11 Aug 2026 03:23:09 +0700 Subject: [PATCH] =?UTF-8?q?feat(worker):=20S3=203b=20h=C3=A4rda=20UPDATE?= =?UTF-8?q?=5FUSER=5FMEMORY=20=E2=80=94=20budget,=20kostnad,=20anti-p?= =?UTF-8?q?=C3=A5hitt,=20GDPR-regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/test/me.test.ts | 88 ++++++- apps/api/test/memory.test.ts | 106 ++++++++ apps/worker/src/context.ts | 3 + apps/worker/src/lib/ai-usage.ts | 41 +++ apps/worker/src/processors/maintenance.ts | 56 ++++- apps/worker/src/processors/scans.ts | 51 +--- apps/worker/test/memory-sync.test.ts | 293 ++++++++++++++++++++++ packages/ai-contracts/src/client.ts | 6 + packages/ai-contracts/src/mock.ts | 23 +- packages/ai-contracts/src/tasks.ts | 5 + packages/memory-client/src/index.ts | 68 +++-- 11 files changed, 678 insertions(+), 62 deletions(-) create mode 100644 apps/worker/src/lib/ai-usage.ts create mode 100644 apps/worker/test/memory-sync.test.ts diff --git a/apps/api/test/me.test.ts b/apps/api/test/me.test.ts index 2780241..1dee28b 100644 --- a/apps/api/test/me.test.ts +++ b/apps/api/test/me.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, beforeAll, afterAll, vi } from "vitest"; import { eq, inArray } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; -import { createDatabase, closeDatabase, schema } from "@app/database"; +import { createDatabase, closeDatabase, eraseUser, schema } from "@app/database"; describe("DELETE /v1/me — GDPR-radering", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); @@ -213,4 +213,90 @@ describe("DELETE /v1/me — GDPR-radering", () => { deleteSpy.mockRestore(); await cleanupUser(email); }); + + it("raderar memoryItems och tasteSignals vid DELETE /v1/me", async () => { + const email = "gdpr-memory-signals@example.invalid"; + await cleanupUser(email); + const { token, userId } = await registerUser(email); + + await testDb.db.insert(schema.memoryItems).values({ + userId, + kind: "structured_fact", + key: "favorite-cuisine-italian", + summarySv: "Gillar italienskt", + value: { favoriteCuisine: "italian" }, + origin: "user_stated", + confidence: 1, + }); + + await testDb.db.insert(schema.tasteSignals).values({ + userId, + axis: "spice", + direction: 1, + strength: 0.8, + origin: "user_stated", + }); + + const res = await app.inject({ + method: "DELETE", + url: "/v1/me", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.statusCode).toBe(200); + + const remainingMemory = await testDb.db + .select({ id: schema.memoryItems.id }) + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(remainingMemory).toHaveLength(0); + + const remainingSignals = await testDb.db + .select({ id: schema.tasteSignals.id }) + .from(schema.tasteSignals) + .where(eq(schema.tasteSignals.userId, userId)); + expect(remainingSignals).toHaveLength(0); + + await cleanupUser(email); + }); + + it("eraseUser raderar memoryItems och tasteSignals (GDPR-regression)", async () => { + const email = "gdpr-erase-user-memory@example.invalid"; + await cleanupUser(email); + const { userId } = await registerUser(email); + + await testDb.db.insert(schema.memoryItems).values({ + userId, + kind: "structured_fact", + key: "dislikes-broccoli", + summarySv: "Ogillar broccoli", + value: { dislikedIngredient: "broccoli" }, + origin: "user_stated", + confidence: 1, + }); + + await testDb.db.insert(schema.tasteSignals).values({ + userId, + axis: "garlic", + direction: -1, + strength: 0.9, + origin: "user_stated", + }); + + await eraseUser(testDb.db, userId); + + const remainingMemory = await testDb.db + .select({ id: schema.memoryItems.id }) + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(remainingMemory).toHaveLength(0); + + const remainingSignals = await testDb.db + .select({ id: schema.tasteSignals.id }) + .from(schema.tasteSignals) + .where(eq(schema.tasteSignals.userId, userId)); + expect(remainingSignals).toHaveLength(0); + + await cleanupUser(email); + }); }); diff --git a/apps/api/test/memory.test.ts b/apps/api/test/memory.test.ts index bd32daf..9656a8c 100644 --- a/apps/api/test/memory.test.ts +++ b/apps/api/test/memory.test.ts @@ -214,3 +214,109 @@ describe("memory i18n parity", () => { } }); }); + +describe("DELETE /v1/me/memory GDPR-regression", () => { + let app: Awaited>; + + beforeAll(async () => { + app = await buildServer(config); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function cleanup(email: string) { + const existing = await testDb.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, email)); + for (const u of existing) { + await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id)); + await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id)); + await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); + await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); + await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id)); + const ownedHouseholds = await testDb.db + .select({ id: schema.households.id }) + .from(schema.households) + .innerJoin( + schema.householdMembers, + eq(schema.householdMembers.householdId, schema.households.id), + ) + .where( + and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")), + ); + for (const h of ownedHouseholds) { + await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); + } + await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); + } + } + + async function registerUser(email: string) { + await cleanup(email); + const res = await app.inject({ + method: "POST", + url: "/v1/auth/register", + payload: { email, password: "Password123!", displayName: "Memory GDPR Test" }, + }); + const body = JSON.parse(res.body) as { accessToken: string }; + const token = body.accessToken; + const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; + await app.inject({ + method: "POST", + url: "/v1/onboarding/quick-start", + headers: { authorization: `Bearer ${token}` }, + payload: { goals: ["cook_more"], precisionMode: "simple" }, + }); + return { token, userId }; + } + + it("tömmer memoryItems och tasteSignals", async () => { + const email = "memory-delete-all@example.invalid"; + const { token, userId } = await registerUser(email); + + await testDb.db.insert(schema.memoryItems).values({ + userId, + kind: "structured_fact", + key: "likes-pasta", + summarySv: "Gillar pasta", + value: { favoriteCuisine: "italian" }, + origin: "user_stated", + confidence: 1, + }); + + await testDb.db.insert(schema.tasteSignals).values({ + userId, + axis: "spice", + direction: 1, + strength: 0.8, + origin: "user_stated", + }); + + const res = await app.inject({ + method: "DELETE", + url: "/v1/me/memory", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.statusCode).toBe(200); + + const remainingMemory = await testDb.db + .select({ id: schema.memoryItems.id }) + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(remainingMemory).toHaveLength(0); + + const remainingSignals = await testDb.db + .select({ id: schema.tasteSignals.id }) + .from(schema.tasteSignals) + .where(eq(schema.tasteSignals.userId, userId)); + expect(remainingSignals).toHaveLength(0); + + await cleanup(email); + }); +}); diff --git a/apps/worker/src/context.ts b/apps/worker/src/context.ts index db87085..3411817 100644 --- a/apps/worker/src/context.ts +++ b/apps/worker/src/context.ts @@ -19,6 +19,8 @@ export interface WorkerContext { db: Database; aamos: AamosClient; redis?: Redis; + /** Delad dagsbudget-store så att memory-jobbet kan spegla skanningens budgetkoll. */ + budgetStore?: BudgetStore; /** Bygger läs-URL för lagrade bilder (samma signaturlogik som API:ts mock-S3). */ readUrl: (key: string) => string; apiBaseUrl: string; @@ -70,6 +72,7 @@ export function createContext(redis?: Redis): WorkerContext { db, aamos, redis, + budgetStore, apiBaseUrl, readUrl: (key: string) => { const sig = createHmac("sha256", secret).update(key).digest("hex").slice(0, 32); diff --git a/apps/worker/src/lib/ai-usage.ts b/apps/worker/src/lib/ai-usage.ts new file mode 100644 index 0000000..de1812d --- /dev/null +++ b/apps/worker/src/lib/ai-usage.ts @@ -0,0 +1,41 @@ +import { sql } from "drizzle-orm"; +import { schema, type Database } from "@app/database"; + +function currentMonth(): string { + const d = new Date(); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; +} + +/** + * Bokför AI-användning utan PII i ai_usage_counters. + * Används av både scan-processor och UPDATE_USER_MEMORY. + */ +export async function recordAiUsage( + db: Database, + userId: string, + usage: { costUsd: number; inputTokens: number; outputTokens: number; aiScans?: number }, +): Promise { + const microcents = Math.round(usage.costUsd * 100_000_000); + const month = currentMonth(); + const aiScans = usage.aiScans ?? 0; + + await db + .insert(schema.aiUsageCounters) + .values({ + userId, + month, + aiScans, + aiTokensIn: usage.inputTokens, + aiTokensOut: usage.outputTokens, + aiCostUsdMicrocents: microcents, + }) + .onConflictDoUpdate({ + target: [schema.aiUsageCounters.userId, schema.aiUsageCounters.month], + set: { + aiScans: sql`${schema.aiUsageCounters.aiScans} + ${aiScans}`, + aiTokensIn: sql`${schema.aiUsageCounters.aiTokensIn} + ${usage.inputTokens}`, + aiTokensOut: sql`${schema.aiUsageCounters.aiTokensOut} + ${usage.outputTokens}`, + aiCostUsdMicrocents: sql`${schema.aiUsageCounters.aiCostUsdMicrocents} + ${microcents}`, + }, + }); +} diff --git a/apps/worker/src/processors/maintenance.ts b/apps/worker/src/processors/maintenance.ts index fa09bc7..410155d 100644 --- a/apps/worker/src/processors/maintenance.ts +++ b/apps/worker/src/processors/maintenance.ts @@ -5,6 +5,12 @@ import { deriveMemoryUpdates } from "@app/memory-client"; import { getLocaleContext } from "../locale.js"; import type { WorkerContext } from "../context.js"; import { cancelTimedOutCookingSessions } from "@app/database"; +import { recordAiUsage } from "../lib/ai-usage.js"; + +/** Pessimistisk kostnadsuppskattning för UPDATE_USER_MEMORY (text-only). */ +const COST_ESTIMATE_PER_MEMORY_SYNC_USD = 0.001; +/** Maxkonfidens för ai_inferred-minnen (R1: låg startkonfidens). */ +const AI_INFERRED_MAX_CONFIDENCE = 0.7; export { processProactiveTips } from "./proactive-tips.js"; @@ -237,6 +243,8 @@ export async function processMealBoxReminders(ctx: WorkerContext): Promise { + const dailyBudgetUsd = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 0); + const users = await ctx.db .select({ userId: schema.userConsents.userId }) .from(schema.userConsents) @@ -250,7 +258,12 @@ export async function processMemorySync(ctx: WorkerContext): Promise { let updates = 0; for (const { userId } of users) { const events = await ctx.db - .select() + .select({ + id: schema.domainEvents.id, + type: schema.domainEvents.type, + occurredAt: schema.domainEvents.occurredAt, + payload: schema.domainEvents.payload, + }) .from(schema.domainEvents) .where( and( @@ -267,6 +280,16 @@ export async function processMemorySync(ctx: WorkerContext): Promise { .limit(100); if (events.length < 3) continue; + // BUDGET: spegla skanningen — över budget får inget AAMOS-anrop göras. + if (dailyBudgetUsd > 0 && ctx.budgetStore) { + const currentSpend = await ctx.budgetStore.getDailySpendUsd(); + if (currentSpend + COST_ESTIMATE_PER_MEMORY_SYNC_USD > dailyBudgetUsd) { + // eslint-disable-next-line no-console + console.log(`UPDATE_USER_MEMORY: skippar ${userId}: daglig budget förbrukad.`); + continue; + } + } + const existing = await ctx.db .select({ key: schema.memoryItems.key }) .from(schema.memoryItems) @@ -279,11 +302,12 @@ export async function processMemorySync(ctx: WorkerContext): Promise { const has = (kind: string) => consents.find((c) => c.kind === kind)?.status === "granted"; const localeContext = await getLocaleContext(ctx, userId); - const proposals = await deriveMemoryUpdates(ctx.aamos, { + const { proposals, usage } = await deriveMemoryUpdates(ctx.aamos, { scope: "user", scopeId: userId, localeContext, events: events.map((e) => ({ + id: e.id, type: e.type, occurredAt: e.occurredAt.toISOString(), payload: e.payload, @@ -296,7 +320,35 @@ export async function processMemorySync(ctx: WorkerContext): Promise { }, }); + // Bokför AI-kostnad/tokens så total dagskostnad inkluderar minnes-AI:n. + if (usage) { + await recordAiUsage(ctx.db, userId, { + costUsd: usage.costUsd, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + }); + } + + const eventIdSet = new Set(events.map((e) => e.id)); + for (const proposal of proposals) { + // R1: varje förslag måste vara grundat i faktiska events. + const hasEventSupport = + proposal.sourceEventIds.length > 0 && + proposal.sourceEventIds.every((id) => eventIdSet.has(id)); + if (!hasEventSupport) { + // eslint-disable-next-line no-console + console.log(`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: saknar event-stöd.`); + continue; + } + + // R1: ai_inferred ska ha låg konfidens. + if (proposal.origin === "ai_inferred" && proposal.confidence > AI_INFERRED_MAX_CONFIDENCE) { + // eslint-disable-next-line no-console + console.log(`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: ai_inferred med för hög confidence.`); + continue; + } + // Skriv aldrig över användarverifierade poster (spec §30: user_stated vinner). const [current] = await ctx.db .select() diff --git a/apps/worker/src/processors/scans.ts b/apps/worker/src/processors/scans.ts index bd28e7d..b5112a3 100644 --- a/apps/worker/src/processors/scans.ts +++ b/apps/worker/src/processors/scans.ts @@ -1,14 +1,11 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { schema, trackProductAnalytics } from "@app/database"; import { scanCompleted, scanFailed } from "@app/analytics"; import type { AamosResult, AamosTaskType, DetectedItem } from "@app/ai-contracts"; import type { WorkerContext } from "../context.js"; import { getLocaleContext } from "../locale.js"; import type { LocaleContext } from "@app/shared-types"; -function currentMonth(): string { - const d = new Date(); - return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; -} +import { recordAiUsage } from "../lib/ai-usage.js"; /** * Bild-/OCR-jobb (spec §54): hämtar scan_job, anropar AAMOS med kontraktvaliderad @@ -84,7 +81,14 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro await recordScanCompleted(ctx, job, result); // Bokför verklig AI-kostnad/tokens utan PII (spec §45). - await recordAiUsage(ctx, job.userId, result); + if (result.costUsd != null || result.inputTokens || result.outputTokens) { + await recordAiUsage(ctx.db, job.userId, { + costUsd: result.costUsd ?? 0, + inputTokens: result.inputTokens ?? 0, + outputTokens: result.outputTokens ?? 0, + aiScans: 1, + }); + } // MEAL_PHOTO_ANALYZED-event för tallriksfoton (spec §55) if (job.jobType === "ANALYZE_MEAL_IMAGE") { @@ -294,41 +298,6 @@ function tokenize(text: string): string[] { .filter((t) => t.length > 1); } -// --------------------------------------------------------------------------- -// AI-kostnadsbokföring utan PII. -// --------------------------------------------------------------------------- - -async function recordAiUsage( - ctx: WorkerContext, - userId: string, - result: AamosResult, -): Promise { - const costUsd = result.costUsd ?? 0; - const tokensIn = result.inputTokens ?? 0; - const tokensOut = result.outputTokens ?? 0; - const microcents = Math.round(costUsd * 100_000_000); - const month = currentMonth(); - - await ctx.db - .insert(schema.aiUsageCounters) - .values({ - userId, - month, - aiScans: 1, - aiTokensIn: tokensIn, - aiTokensOut: tokensOut, - aiCostUsdMicrocents: microcents, - }) - .onConflictDoUpdate({ - target: [schema.aiUsageCounters.userId, schema.aiUsageCounters.month], - set: { - aiScans: sql`${schema.aiUsageCounters.aiScans} + 1`, - aiTokensIn: sql`${schema.aiUsageCounters.aiTokensIn} + ${tokensIn}`, - aiTokensOut: sql`${schema.aiUsageCounters.aiTokensOut} + ${tokensOut}`, - aiCostUsdMicrocents: sql`${schema.aiUsageCounters.aiCostUsdMicrocents} + ${microcents}`, - }, - }); -} function classifyScanError(error?: string | null): string { if (!error) return "unknown"; diff --git a/apps/worker/test/memory-sync.test.ts b/apps/worker/test/memory-sync.test.ts new file mode 100644 index 0000000..934968e --- /dev/null +++ b/apps/worker/test/memory-sync.test.ts @@ -0,0 +1,293 @@ +import "./setup-env.js"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { eq } from "drizzle-orm"; +import { createDatabase, closeDatabase, schema } from "@app/database"; +import { processMemorySync } from "../src/processors/maintenance.js"; +import { MemoryBudgetStore } from "@app/ai-contracts"; +import type { WorkerContext } from "../src/context.js"; +import type { AamosClient, AamosResult } from "@app/ai-contracts"; + +const testDb = createDatabase(process.env.TEST_DATABASE_URL!); +const email = "memory-sync-test@example.invalid"; + +describe("UPDATE_USER_MEMORY hardening", () => { + async function cleanup() { + const existing = await testDb.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, email)); + for (const u of existing) { + await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id)); + await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id)); + await testDb.db.delete(schema.domainEvents).where(eq(schema.domainEvents.userId, u.id)); + await testDb.db.delete(schema.aiUsageCounters).where(eq(schema.aiUsageCounters.userId, u.id)); + await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); + await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); + await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id)); + await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id)); + await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); + } + } + + beforeAll(async () => { + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDatabase(); + }); + + async function setupUserWithEvents(): Promise<{ userId: string; eventIds: string[] }> { + await cleanup(); + const [user] = await testDb.db + .insert(schema.users) + .values({ + email, + passwordHash: "not-used", + displayName: "Memory Sync Test", + }) + .returning(); + const userId = user!.id; + + await testDb.db.insert(schema.userConsents).values([ + { userId, kind: "personalization", status: "granted" }, + { userId, kind: "anonymized_improvement", status: "granted" }, + { userId, kind: "image_training", status: "granted" }, + ]); + + const events = await testDb.db + .insert(schema.domainEvents) + .values([ + { + type: "RECIPE_COOKED", + userId, + payload: { recipeId: "r1", recipeTitleSv: "Kycklingpasta" }, + }, + { + type: "RECIPE_RATED", + userId, + payload: { recipeId: "r1", stars: 5 }, + }, + { + type: "MEAL_LOGGED", + userId, + payload: { mealType: "dinner" }, + }, + ]) + .returning(); + + return { userId, eventIds: events.map((e) => e.id) }; + } + + function makeContext(aamos: AamosClient, budgetStore?: MemoryBudgetStore): WorkerContext { + return { + db: testDb.db, + aamos, + budgetStore, + apiBaseUrl: "http://localhost:4000", + readUrl: (key: string) => `http://localhost:4000/mock-s3/${key}`, + close: async () => {}, + }; + } + + it("skriver grundade förslag från events", async () => { + const { userId, eventIds } = await setupUserWithEvents(); + + const aamos = { + async runTask() { + return { + status: "ok", + output: { + memoryUpdates: eventIds.map((id) => ({ + key: `likes_${id}`, + kind: "structured_fact", + summarySv: "Gillar kycklingpasta", + value: { recipeId: "r1" }, + origin: "observed", + confidence: 0.75, + expiresAt: null, + sourceEventIds: [id], + })), + }, + costUsd: 0.001, + inputTokens: 100, + outputTokens: 50, + } as AamosResult<"UPDATE_USER_MEMORY">; + }, + async healthCheck() { + return { ok: true }; + }, + } as AamosClient; + + const updates = await processMemorySync(makeContext(aamos)); + expect(updates).toBe(eventIds.length); + + const items = await testDb.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(items.length).toBe(eventIds.length); + + const usage = await testDb.db + .select() + .from(schema.aiUsageCounters) + .where(eq(schema.aiUsageCounters.userId, userId)); + expect(usage).toHaveLength(1); + expect(usage[0]!.aiCostUsdMicrocents).toBe(100_000); // 0.001 USD + expect(usage[0]!.aiTokensIn).toBe(100); + expect(usage[0]!.aiTokensOut).toBe(50); + }); + + it("avvisar fabricerade förslag utan event-stöd", async () => { + const { userId, eventIds } = await setupUserWithEvents(); + + const aamos = { + async runTask() { + return { + status: "ok", + output: { + memoryUpdates: [ + { + key: "fabricated_likes_sushi", + kind: "structured_fact", + summarySv: "Gillar sushi", + value: { favoriteCuisine: "japanese" }, + origin: "ai_inferred", + confidence: 0.5, + expiresAt: null, + sourceEventIds: ["non-existent-event-id"], + }, + { + key: "grounded_likes_pasta", + kind: "structured_fact", + summarySv: "Gillar pasta", + value: { recipeId: "r1" }, + origin: "observed", + confidence: 0.8, + expiresAt: null, + sourceEventIds: [eventIds[0]!], + }, + ], + }, + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + } as AamosResult<"UPDATE_USER_MEMORY">; + }, + async healthCheck() { + return { ok: true }; + }, + } as AamosClient; + + const updates = await processMemorySync(makeContext(aamos)); + expect(updates).toBe(1); + + const items = await testDb.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(items.map((i) => i.key)).toEqual(["grounded_likes_pasta"]); + }); + + it("avvisar ai_inferred med för hög confidence", async () => { + const { userId, eventIds } = await setupUserWithEvents(); + + const aamos = { + async runTask() { + return { + status: "ok", + output: { + memoryUpdates: [ + { + key: "overconfident", + kind: "structured_fact", + summarySv: "Gillar pasta", + value: { recipeId: "r1" }, + origin: "ai_inferred", + confidence: 0.95, + expiresAt: null, + sourceEventIds: [eventIds[0]!], + }, + ], + }, + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + } as AamosResult<"UPDATE_USER_MEMORY">; + }, + async healthCheck() { + return { ok: true }; + }, + } as AamosClient; + + const updates = await processMemorySync(makeContext(aamos)); + expect(updates).toBe(0); + + const items = await testDb.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(items).toHaveLength(0); + }); + + it("hoppas över anropet och skriver inget när daglig budget är förbrukad", async () => { + const { userId } = await setupUserWithEvents(); + const previousBudget = process.env.GEMINI_DAILY_BUDGET_USD; + process.env.GEMINI_DAILY_BUDGET_USD = "0.0005"; + + const budgetStore = new MemoryBudgetStore(); + await budgetStore.incrementDailySpendUsd(0.0004); // redan nära taket + + let called = false; + const aamos = { + async runTask() { + called = true; + return { status: "ok", output: { memoryUpdates: [] }, costUsd: 0, inputTokens: 0, outputTokens: 0 } as AamosResult<"UPDATE_USER_MEMORY">; + }, + async healthCheck() { + return { ok: true }; + }, + } as AamosClient; + + const updates = await processMemorySync(makeContext(aamos, budgetStore)); + expect(updates).toBe(0); + expect(called).toBe(false); + + const items = await testDb.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(items).toHaveLength(0); + + process.env.GEMINI_DAILY_BUDGET_USD = previousBudget; + }); + + it("förkastar trasigt AAMOS-svar helt utan delskrivning", async () => { + const { userId } = await setupUserWithEvents(); + + const aamos = { + async runTask() { + return { + status: "ok", + output: { memoryUpdates: [{ broken: true }] }, + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + } as unknown as AamosResult<"UPDATE_USER_MEMORY">; + }, + async healthCheck() { + return { ok: true }; + }, + } as AamosClient; + + const updates = await processMemorySync(makeContext(aamos)); + expect(updates).toBe(0); + + const items = await testDb.db + .select() + .from(schema.memoryItems) + .where(eq(schema.memoryItems.userId, userId)); + expect(items).toHaveLength(0); + }); +}); diff --git a/packages/ai-contracts/src/client.ts b/packages/ai-contracts/src/client.ts index 8702e17..fe567b2 100644 --- a/packages/ai-contracts/src/client.ts +++ b/packages/ai-contracts/src/client.ts @@ -22,6 +22,8 @@ export interface AamosCallOptions { anonymizedImprovement: boolean; imageTraining: boolean; }; + /** Systeminstruktion som skickas med till AAMOS (t.ex. anti-påhitt-regler). */ + systemInstruction?: string; } export interface AamosResult { @@ -118,6 +120,9 @@ export class HttpAamosClient implements AamosClient { anonymizedImprovement: false, imageTraining: false, }, + ...(options.systemInstruction + ? { systemInstruction: options.systemInstruction } + : {}), }, }; @@ -219,6 +224,7 @@ export class MockAamosClient implements AamosClient { async runTask( taskType: T, input: TaskInput, + _options?: AamosCallOptions, ): Promise> { const contract = TASK_CONTRACTS[taskType]; const parsedInput = contract.input.safeParse(input); diff --git a/packages/ai-contracts/src/mock.ts b/packages/ai-contracts/src/mock.ts index f484f80..64ba80a 100644 --- a/packages/ai-contracts/src/mock.ts +++ b/packages/ai-contracts/src/mock.ts @@ -289,8 +289,27 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown case "PARSE_CRAVING": return { tags: ["creamy"], cuisine: null, maxKcal: null, confidence: 0.6 }; - case "UPDATE_USER_MEMORY": - return { memoryUpdates: [] }; + case "UPDATE_USER_MEMORY": { + const events = + typeof input === "object" && input !== null + ? ((input as { events?: Array<{ id?: string; type?: string }> }).events ?? []) + : []; + const eventIds = events.map((e) => e.id).filter((id): id is string => id != null); + if (eventIds.length === 0) return { memoryUpdates: [] }; + // Mock: ett grundat förslag per faktiskt event (utan att hitta på nya). + return { + memoryUpdates: eventIds.map((id) => ({ + key: `event_${id}`, + kind: "event" as const, + summarySv: "Observerad från event", + value: { eventId: id }, + origin: "observed" as const, + confidence: 0.75, + expiresAt: null, + sourceEventIds: [id], + })), + }; + } case "GENERATE_WEEK_PLAN": return { entries: [], confidence: 0.5 }; diff --git a/packages/ai-contracts/src/tasks.ts b/packages/ai-contracts/src/tasks.ts index 699b672..37484e2 100644 --- a/packages/ai-contracts/src/tasks.ts +++ b/packages/ai-contracts/src/tasks.ts @@ -377,6 +377,7 @@ export const updateUserMemoryInput = z.object({ scopeId: z.string(), events: z.array( z.object({ + id: z.string(), type: z.string(), occurredAt: z.string(), payload: z.unknown(), @@ -394,6 +395,8 @@ export const updateUserMemoryOutput = z.object({ origin: z.enum(["observed", "ai_inferred"]), confidence, expiresAt: z.string().nullable().default(null), + /** Händelse-ID:n som stödjer förslaget – obligatoriskt för grundningskoll (R1). */ + sourceEventIds: z.array(z.string()).min(1), }), ), }); @@ -585,6 +588,8 @@ export const aamosRequestEnvelopeSchema = z.object({ anonymizedImprovement: z.boolean(), imageTraining: z.boolean(), }), + /** Extra systeminstruktion som AAMOS ska lägga till i prompten. */ + systemInstruction: z.string().optional(), }), }); export type AamosRequestEnvelope = z.infer; diff --git a/packages/memory-client/src/index.ts b/packages/memory-client/src/index.ts index d904f04..991113c 100644 --- a/packages/memory-client/src/index.ts +++ b/packages/memory-client/src/index.ts @@ -1,4 +1,4 @@ -import type { AamosClient } from "@app/ai-contracts"; +import { TASK_CONTRACTS, type AamosClient } from "@app/ai-contracts"; import type { MemoryItem, MemoryKind, SignalOrigin } from "@app/shared-types"; /** @@ -21,12 +21,14 @@ export interface MemoryUpdateProposal { 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<{ type: string; occurredAt: string; payload: unknown }>; + events: Array<{ id: string; type: string; occurredAt: string; payload: unknown }>; existingMemoryKeys: string[]; consentFlags: { personalization: boolean; @@ -38,15 +40,23 @@ export interface MemorySyncInput { } /** - * Kör minnesuppdatering via AAMOS. Returnerar förslag – anroparen (workern) - * persisterar dem i memory_items och publicerar MEMORY_UPDATED-event. + * 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 { - if (!input.consentFlags.personalization) return []; +): 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", @@ -59,21 +69,47 @@ export async function deriveMemoryUpdates( { 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 } : {}), }, ); - 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, - })); + 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 {