/** * 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":"", "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 async function extractAndStoreMemories( userId: string, conversation: Array<{ role: string; content: string }>, sessionId?: string, openrouterKey?: string, ): Promise { 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); } }