169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
/**
|
|
* POST /api/lyra/memories/extract
|
|
*
|
|
* Extrahiert strukturierte Memories aus einem SOS/Coach-Gespräch via LLM (Claude Haiku).
|
|
* Wird intern (fire-and-forget) nach SOS-Stream-Ende aufgerufen.
|
|
*
|
|
* Body: { sessionId: string, conversation: Array<{role, content}> }
|
|
* Response: { extracted: number, skipped: number }
|
|
*
|
|
* Fehler: silent — User darf nichts merken. Logging via [lyra-memory].
|
|
*/
|
|
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.`;
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
|
|
const body = await readBody(event);
|
|
const {
|
|
sessionId,
|
|
conversation,
|
|
}: {
|
|
sessionId?: string;
|
|
conversation: Array<{ role: string; content: string }>;
|
|
} = body;
|
|
|
|
if (!Array.isArray(conversation) || conversation.length === 0) {
|
|
throw createError({ statusCode: 400, message: "conversation fehlt" });
|
|
}
|
|
|
|
const config = useRuntimeConfig();
|
|
const key = config.openrouterApiKey as string | undefined;
|
|
if (!key) {
|
|
throw createError({ statusCode: 503, message: "OpenRouter Key fehlt" });
|
|
}
|
|
|
|
// Nur User-Nachrichten extrahieren (Lyra-Antworten sind Kontext, kein User-Fakt)
|
|
const userMessages = conversation.filter((m) => m.role === "user");
|
|
if (userMessages.length === 0) {
|
|
return { extracted: 0, skipped: 0 };
|
|
}
|
|
|
|
// Konversation als lesbaren Text aufbereiten (max 4000 chars für Haiku)
|
|
const conversationText = conversation
|
|
.slice(-20) // letzte 20 Messages reichen für Kontext
|
|
.map((m) => `${m.role === "user" ? "User" : "Lyra"}: ${m.content}`)
|
|
.join("\n")
|
|
.slice(0, 4000);
|
|
|
|
let extracted = 0;
|
|
let skipped = 0;
|
|
|
|
try {
|
|
const res = await $fetch<{
|
|
choices: { message: { content: string } }[];
|
|
}>("https://openrouter.ai/api/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${key}`,
|
|
"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) {
|
|
console.log("[lyra-memory] extract: empty response from LLM");
|
|
return { extracted: 0, skipped: 0 };
|
|
}
|
|
|
|
// JSON-Array parsen (Haiku gibt manchmal Markdown-Fences zurück)
|
|
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 { extracted: 0, skipped: 0 };
|
|
}
|
|
|
|
if (!Array.isArray(facts)) {
|
|
return { extracted: 0, skipped: 0 };
|
|
}
|
|
|
|
for (const fact of facts) {
|
|
// Confidence < 0.5 → skip
|
|
if (!fact.content || (fact.confidence ?? 0) < 0.5) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Typ validieren
|
|
if (!VALID_TYPES.includes(fact.type as LyraMemoryType)) {
|
|
console.warn("[lyra-memory] extract: invalid type:", fact.type);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await upsertMemory(
|
|
user.id,
|
|
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);
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[lyra-memory] extract done for ${user.id}: ${extracted} extracted, ${skipped} skipped`,
|
|
);
|
|
} catch (e) {
|
|
// Silent fail — User darf nichts merken
|
|
console.error("[lyra-memory] extract LLM error:", e);
|
|
return { extracted: 0, skipped: 0 };
|
|
}
|
|
|
|
return { success: true, data: { extracted, skipped } };
|
|
});
|