194 lines
5.7 KiB
TypeScript
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);
|
|
}
|