/** * DB-Layer: LyraMemory * * Strukturierte persistente User-Erinnerungen für den Lyra-Coach. * Enthält Art-9-Gesundheitsdaten — kein direkter Zugriff außer über diese Funktionen. * * Constraints: * - Max MAX_MEMORIES_PER_USER pro User (Datenminimierung + System-Prompt-Budget) * - upsertMemory: ähnlicher Content (Substring) → Update statt Insert */ import { usePrisma } from "../utils/prisma"; import type { LyraMemoryType } from "../generated/prisma"; export type { LyraMemoryType }; export interface LyraMemoryRow { id: string; userId: string; type: LyraMemoryType; content: string; confidence: number; source: string | null; createdAt: Date; updatedAt: Date; lastReferencedAt: Date | null; } const MAX_MEMORIES_PER_USER = 30; const LOG = "[lyra-memory]"; /** * Alle Memories eines Users, sortiert nach Relevanz (zuletzt referenziert → neueste). */ export async function getMemoriesForUser( userId: string, ): Promise { const db = usePrisma(); return db.lyraMemory.findMany({ where: { userId }, orderBy: [ { lastReferencedAt: { sort: "desc", nulls: "last" } }, { createdAt: "desc" }, ], }) as Promise; } /** * Upsert: wenn Content-Substring eines bestehenden Eintrags gleichen Typs * bereits vorhanden ist → update confidence + source. Sonst insert. * Hält Max-Constraint ein: bei > MAX_MEMORIES_PER_USER werden älteste * mit niedrigster confidence gelöscht. */ export async function upsertMemory( userId: string, type: LyraMemoryType, content: string, source?: string, confidence = 0.7, ): Promise { const db = usePrisma(); const trimmedContent = content.slice(0, 500).trim(); // Similarity-Check: existierende Memories gleichen Typs laden const existing = await db.lyraMemory.findMany({ where: { userId, type }, select: { id: true, content: true, confidence: true }, }); // Substring-Match (case-insensitive) const contentLower = trimmedContent.toLowerCase(); const match = existing.find((m) => { const mLower = m.content.toLowerCase(); return ( mLower.includes(contentLower) || contentLower.includes(mLower) || // 70%-Overlap-Heuristik für kurze Strings (contentLower.length > 20 && mLower.length > 20 && levenshteinSimilarity(contentLower, mLower) > 0.7) ); }); let result: LyraMemoryRow; if (match) { // Update: nimm höhere confidence + neuen Source const newConfidence = Math.max(match.confidence, confidence); result = (await db.lyraMemory.update({ where: { id: match.id }, data: { content: trimmedContent, confidence: newConfidence, source: source ?? undefined, updatedAt: new Date(), }, })) as LyraMemoryRow; console.log( `${LOG} updated memory ${match.id} (type=${type}, conf=${newConfidence})`, ); } else { result = (await db.lyraMemory.create({ data: { userId, type, content: trimmedContent, confidence, source: source ?? null, }, })) as LyraMemoryRow; console.log( `${LOG} created memory ${result.id} (type=${type}, conf=${confidence})`, ); // Max-Constraint enforzen await enforceMaxMemories(userId); } return result; } /** * Markiert Memories als zuletzt referenziert (in System-Prompt injiziert). * Fire-and-forget geeignet — wirft nicht. */ export async function markReferenced(memoryIds: string[]): Promise { if (!memoryIds.length) return; const db = usePrisma(); try { await db.lyraMemory.updateMany({ where: { id: { in: memoryIds } }, data: { lastReferencedAt: new Date() }, }); console.log(`${LOG} markReferenced: ${memoryIds.length} memories`); } catch (e) { console.error(`${LOG} markReferenced error:`, e); } } /** * User-seitiges Delete (V2.5-ready — Endpoint kommt später). */ export async function deleteMemoryById( userId: string, memoryId: string, ): Promise { const db = usePrisma(); await db.lyraMemory.deleteMany({ where: { id: memoryId, userId }, }); console.log(`${LOG} deleted memory ${memoryId} for user ${userId}`); } // ── Interne Helpers ────────────────────────────────────────────────────────── async function enforceMaxMemories(userId: string): Promise { const db = usePrisma(); const total = await db.lyraMemory.count({ where: { userId } }); if (total <= MAX_MEMORIES_PER_USER) return; const overflow = total - MAX_MEMORIES_PER_USER; // Älteste mit niedrigster confidence zuerst löschen const candidates = await db.lyraMemory.findMany({ where: { userId }, orderBy: [{ confidence: "asc" }, { createdAt: "asc" }], take: overflow, select: { id: true }, }); const ids = candidates.map((c) => c.id); await db.lyraMemory.deleteMany({ where: { id: { in: ids } } }); console.log(`${LOG} enforceMax: deleted ${ids.length} old memories`); } /** * Einfache Levenshtein-basierte Ähnlichkeit (0.0-1.0). * Nur für kurze Strings — O(n*m) ist ok für max 500 chars. */ function levenshteinSimilarity(a: string, b: string): number { if (a === b) return 1; const la = a.length; const lb = b.length; const dp: number[][] = Array.from({ length: la + 1 }, (_, i) => Array.from({ length: lb + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), ); for (let i = 1; i <= la; i++) { for (let j = 1; j <= lb; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } return 1 - dp[la][lb] / Math.max(la, lb); }