rebreak-monorepo/backend/server/utils/lyraMemoryExtract.ts
chahinebrini 7f529c3be3 feat(privacy): Coach-Payload an LLM-Provider pseudonymisieren (Art.9/DSGVO)
Schliesst hans-muellers K1-Befund (Datenschutz-Audit): der Coach-Prompt
sendete Identifier + Art.9-nahe Daten an US-LLMs (Gemini/OpenAI/Anthropic).

- message.post.ts: Geburtsjahr/exaktes Alter -> Altersgruppe (Dekaden-Bucket);
  Stadt komplett entfernt (Bundesland bleibt). Geschlecht/Familienstand/Beruf/
  Nickname unveraendert (gewollte Personalisierung; Nickname = Pseudonym).
- lyraMemoryExtract.ts: Extraction-Prompt reduziert Dritt-Klarnamen auf Rolle
  ("Frau Maria" -> "seine Frau"), keine Orte/Arbeitgeber im Memory-Content.
- 08-datenschutz-audit: Payload-Audit-Platzhalter durch Vorher/Nachher-Tabelle
  ersetzt, K1 erledigt, ZDR-Update (DPA/SCCs deemed-signed, TIA offen).

Pseudonymisierung zaehlt jetzt als zweite Schutzmassnahme neben ZDR fuer die TIA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:35:13 +02:00

125 lines
4.1 KiB
TypeScript

/**
* Lyra Memory Extraction — interne Funktion (ohne HTTP-Roundtrip).
*
* Wird von sos-stream.get.ts fire-and-forget nach Stream-Ende aufgerufen.
* Fehler sind immer silent — darf NIE die User-Experience beeinflussen.
*/
import { upsertMemory } from "../db/lyraMemory";
import type { LyraMemoryType } from "../db/lyraMemory";
const VALID_TYPES: LyraMemoryType[] = [
"trigger",
"habit",
"strength",
"relationship",
"milestone",
"pain_point",
"goal",
"preference",
];
const EXTRACTION_SYSTEM_PROMPT = `Du extrahierst aus einem Gespräch zwischen User und Lyra-Coach strukturierte Fakten über den User. Output strikt als JSON-Array:
[{"type":"trigger|habit|strength|relationship|milestone|pain_point|goal|preference", "content":"<kurzer Fakt, max 200 chars>", "confidence":0.0-1.0}]
Regeln:
- Nur Fakten die der USER explizit oder implizit über sich geteilt hat. Nichts erfinden.
- Keine Vermutungen über Diagnosen oder Pathologisierungen ("süchtig", "krank" etc).
- Nur Wesentliches. Wenn nichts Neues drin ist → leeres Array [].
- confidence: 0.9+ wenn User explizit gesagt, 0.5-0.7 wenn implizit, <0.5 garnicht extrahieren.
- content in der Sprache des Gesprächs (DE).
- Maximal 8 Einträge pro Extraktion.
- DATENSCHUTZ — Klarnamen Dritter (echte Vornamen/Nachnamen von Personen im Umfeld des Users, z.B. "seine Frau Maria", "sein Freund Thomas") NIEMALS speichern. Ersetze durch Rolle/Beziehung ("seine Frau", "ein enger Freund"). Nur die Beziehung ist relevant, nicht der Name.
- DATENSCHUTZ — Keine Orte, Adressen, Arbeitgeber-Namen oder andere lokalisierenden Angaben im content.`;
export async function extractAndStoreMemories(
userId: string,
conversation: Array<{ role: string; content: string }>,
sessionId?: string,
openrouterKey?: string,
): Promise<void> {
if (!openrouterKey) {
console.warn("[lyra-memory] extract: no OpenRouter key");
return;
}
const userMessages = conversation.filter((m) => m.role === "user");
if (userMessages.length === 0) return;
const conversationText = conversation
.slice(-20)
.map((m) => `${m.role === "user" ? "User" : "Lyra"}: ${m.content}`)
.join("\n")
.slice(0, 4000);
try {
const res = await $fetch<{
choices: { message: { content: string } }[];
}>("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${openrouterKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://rebreak.org",
"X-Title": "ReBreak Memory Extraction",
},
body: {
model: "anthropic/claude-haiku-4-5",
max_tokens: 800,
temperature: 0.1,
messages: [
{ role: "system", content: EXTRACTION_SYSTEM_PROMPT },
{ role: "user", content: conversationText },
],
},
timeout: 20000,
});
const raw = res.choices?.[0]?.message?.content?.trim();
if (!raw) return;
const jsonStr = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/, "")
.trim();
let facts: Array<{ type: string; content: string; confidence: number }> =
[];
try {
facts = JSON.parse(jsonStr);
} catch {
console.warn(
"[lyra-memory] extract: JSON parse failed:",
jsonStr.slice(0, 200),
);
return;
}
if (!Array.isArray(facts)) return;
let extracted = 0;
for (const fact of facts) {
if (!fact.content || (fact.confidence ?? 0) < 0.5) continue;
if (!VALID_TYPES.includes(fact.type as LyraMemoryType)) continue;
try {
await upsertMemory(
userId,
fact.type as LyraMemoryType,
fact.content,
sessionId ?? "sos-session",
Math.min(1, Math.max(0, fact.confidence ?? 0.7)),
);
extracted++;
} catch (e) {
console.error("[lyra-memory] upsert error:", e);
}
}
console.log(
`[lyra-memory] async extract done for ${userId}: ${extracted} stored`,
);
} catch (e) {
// Silent — darf User nie beeinflussen
console.error("[lyra-memory] extract error (silent):", e);
}
}