194 lines
5.7 KiB
TypeScript

/**
* 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<LyraMemoryRow[]> {
const db = usePrisma();
return db.lyraMemory.findMany({
where: { userId },
orderBy: [
{ lastReferencedAt: { sort: "desc", nulls: "last" } },
{ createdAt: "desc" },
],
}) as Promise<LyraMemoryRow[]>;
}
/**
* 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<LyraMemoryRow> {
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<void> {
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<void> {
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<void> {
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);
}