// email/summary.go – LLM-Zusammenfassung von Emails via LocalAI package email import ( "context" "fmt" "log/slog" "strings" "time" openai "github.com/sashabaranov/go-openai" "my-brain-importer/internal/config" "my-brain-importer/internal/triage" ) // Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen. func Summarize() (string, error) { accounts := config.AllEmailAccounts() if len(accounts) == 0 { return "", fmt.Errorf("Kein Email-Account konfiguriert") } if len(accounts) == 1 { return fetchAndSummarizeAccount(accounts[0], 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.") } var parts []string for _, acc := range accounts { result, err := fetchAndSummarizeAccount(acc, 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.") if err != nil { slog.Error("Email-Fehler", "account", acc.Name, "fehler", err) parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) continue } parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result)) } return strings.Join(parts, "\n\n"), nil } // SummarizeUnread fasst ungelesene Emails für alle konfigurierten Accounts zusammen. // Wenn email.processed_folder konfiguriert ist, werden Emails danach dorthin verschoben. func SummarizeUnread() (string, error) { accounts := config.AllEmailAccounts() if len(accounts) == 0 { return "", fmt.Errorf("Kein Email-Account konfiguriert") } if len(accounts) == 1 { return SummarizeUnreadAccount(accounts[0]) } var parts []string allEmpty := true for _, acc := range accounts { result, err := SummarizeUnreadAccount(acc) if err != nil { slog.Error("Email-Fehler", "account", acc.Name, "fehler", err) parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) continue } if result != "📭 Keine ungelesenen Emails." { allEmpty = false parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result)) } } if allEmpty { return "📭 Keine ungelesenen Emails.", nil } return strings.Join(parts, "\n\n"), nil } // SummarizeUnreadAccount fasst ungelesene Emails für einen bestimmten Account zusammen. // Wenn triage_folder konfiguriert ist, werden unwichtige Emails vorher aussortiert. func SummarizeUnreadAccount(acc config.EmailAccount) (string, error) { // Phase 1: Triage – Emails sortieren (eigene Verbindung) if acc.TriageUnimportantFolder != "" || acc.TriageImportantFolder != "" { if err := triageUnread(acc); err != nil { slog.Warn("[Triage] fehlgeschlagen, übersprungen", "account", accountLabel(acc), "fehler", err) } } // Phase 2: Zusammenfassung der verbleibenden wichtigen Emails (frische Verbindung) cl, err := ConnectAccount(acc) if err != nil { return "", fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err) } defer cl.Close() var msgs []Message var seqNums []uint32 if acc.ProcessedFolder != "" { msgs, seqNums, err = cl.FetchUnreadSeqNums() } else { msgs, err = cl.FetchUnread() } if err != nil { return "", fmt.Errorf("Emails abrufen (%s): %w", accountLabel(acc), err) } if len(msgs) == 0 { return "📭 Keine ungelesenen Emails.", nil } slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs), "typ", "unread") result, err := summarizeWithLLMModel(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.", accountModel(acc)) if err != nil { return "", err } // Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben if acc.ProcessedFolder != "" && len(seqNums) > 0 { if moveErr := cl.MoveMessages(seqNums, acc.ProcessedFolder); moveErr != nil { slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", acc.ProcessedFolder) } else { slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", acc.ProcessedFolder) } } return result, nil } // TriageRecentAllAccounts klassifiziert die letzten n Emails aller Accounts manuell // und verschiebt sie in die konfigurierten Triage-Ordner. func TriageRecentAllAccounts(n uint32) (string, error) { accounts := config.AllEmailAccounts() if len(accounts) == 0 { return "", fmt.Errorf("kein Email-Account konfiguriert") } var lines []string for _, acc := range accounts { if acc.TriageImportantFolder == "" && acc.TriageUnimportantFolder == "" { lines = append(lines, fmt.Sprintf("⚠️ **%s:** kein triage_important_folder / triage_unimportant_folder konfiguriert", accountLabel(acc))) continue } wichtig, unwichtig, err := triageRecentAccount(acc, n) if err != nil { lines = append(lines, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) continue } lines = append(lines, fmt.Sprintf("✅ **%s:** %d wichtig → `%s`, %d unwichtig → `%s`", accountLabel(acc), wichtig, acc.TriageImportantFolder, unwichtig, acc.TriageUnimportantFolder)) } return strings.Join(lines, "\n"), nil } // triageRecentAccount klassifiziert die letzten n Emails eines Accounts. // Gibt Anzahl wichtiger und unwichtiger Emails zurück. func triageRecentAccount(acc config.EmailAccount, n uint32) (wichtig, unwichtig int, err error) { cl, err := ConnectAccount(acc) if err != nil { return 0, 0, fmt.Errorf("verbinden: %w", err) } defer cl.Close() // Ordner vorab anlegen, unabhängig davon ob Emails verschoben werden if acc.TriageImportantFolder != "" { if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil { slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err) } } if acc.TriageUnimportantFolder != "" { if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil { slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err) } } msgs, err := cl.FetchRecentForSelect(n) if err != nil { return 0, 0, fmt.Errorf("fetch: %w", err) } if len(msgs) == 0 { return 0, 0, nil } model := accountModel(acc) var wichtigSeqNums, unwichtigSeqNums []uint32 slog.Info("[Triage] Manuell gestartet", "account", accountLabel(acc), "anzahl", len(msgs)) for _, msg := range msgs { if ClassifyImportance(msg.Message, model) { wichtigSeqNums = append(wichtigSeqNums, msg.SeqNum) } else { unwichtigSeqNums = append(unwichtigSeqNums, msg.SeqNum) } } if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 { if ensureErr := cl.EnsureFolder(acc.TriageUnimportantFolder); ensureErr != nil { slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", ensureErr) } if moveErr := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); moveErr != nil { slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "fehler", moveErr) } } if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 { if ensureErr := cl.EnsureFolder(acc.TriageImportantFolder); ensureErr != nil { slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", ensureErr) } if moveErr := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); moveErr != nil { slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "fehler", moveErr) } } return len(wichtigSeqNums), len(unwichtigSeqNums), nil } // triageUnread klassifiziert alle ungelesenen Emails eines Accounts und verschiebt // wichtige in TriageImportantFolder und unwichtige in TriageUnimportantFolder. // Läuft sequentiell: eine Email nach der anderen. func triageUnread(acc config.EmailAccount) error { cl, err := ConnectAccount(acc) if err != nil { return fmt.Errorf("verbinden: %w", err) } defer cl.Close() // Ordner vorab anlegen if acc.TriageImportantFolder != "" { cl.EnsureFolder(acc.TriageImportantFolder) } if acc.TriageUnimportantFolder != "" { cl.EnsureFolder(acc.TriageUnimportantFolder) } msgs, seqNums, err := cl.FetchUnreadSeqNums() if err != nil { return fmt.Errorf("fetch: %w", err) } if len(msgs) == 0 { return nil } model := accountModel(acc) var wichtigSeqNums, unwichtigSeqNums []uint32 slog.Info("[Triage] Klassifizierung gestartet", "account", accountLabel(acc), "anzahl", len(msgs)) for i, msg := range msgs { if ClassifyImportance(msg, model) { wichtigSeqNums = append(wichtigSeqNums, seqNums[i]) } else { unwichtigSeqNums = append(unwichtigSeqNums, seqNums[i]) } } if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 { if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil { slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageUnimportantFolder, "fehler", err) } if err := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); err != nil { slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err) } else { slog.Info("[Triage] Unwichtige Emails verschoben", "anzahl", len(unwichtigSeqNums), "ordner", acc.TriageUnimportantFolder) } } if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 { if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil { slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageImportantFolder, "fehler", err) } if err := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); err != nil { slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err) } else { slog.Info("[Triage] Wichtige Emails verschoben", "anzahl", len(wichtigSeqNums), "ordner", acc.TriageImportantFolder) } } return nil } // ClassifyImportance klassifiziert eine einzelne Email als wichtig (true) oder unwichtig (false). // Sucht zuerst ähnliche vergangene Entscheidungen in Qdrant (RAG) und gibt sie als Kontext mit. // Im Fehlerfall oder bei unklarer Antwort wird true (wichtig) zurückgegeben – sicherer Default. func ClassifyImportance(msg Message, model string) bool { // RAG: ähnliche vergangene Triage-Entscheidungen als Few-Shot-Beispiele ragQuery := fmt.Sprintf("Von: %s Betreff: %s", msg.From, msg.Subject) examples := triage.SearchSimilar(ragQuery) var examplesText string if len(examples) > 0 { var sb strings.Builder sb.WriteString("Ähnliche Entscheidungen aus der Vergangenheit:\n") for _, ex := range examples { sb.WriteString("- ") sb.WriteString(ex.Text) sb.WriteString("\n") } sb.WriteString("\n") examplesText = sb.String() } prompt := fmt.Sprintf("%sVon: %s\nBetreff: %s\n\nIst diese Email wichtig? Antworte NUR mit einem einzigen Wort: wichtig oder unwichtig.", examplesText, msg.From, msg.Subject) chatClient := config.NewChatClient() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := chatClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ Model: model, Messages: []openai.ChatCompletionMessage{ {Role: openai.ChatMessageRoleSystem, Content: "Du bist ein Email-Filter. Antworte immer nur mit einem einzigen Wort: wichtig oder unwichtig."}, {Role: openai.ChatMessageRoleUser, Content: prompt}, }, Temperature: 0.1, MaxTokens: 300, }) if err != nil { slog.Warn("[Triage] LLM-Fehler, Email als wichtig eingestuft", "betreff", msg.Subject, "fehler", err) return true } if len(resp.Choices) == 0 { return true } raw := resp.Choices[0].Message.Content // Reasoning-Modelle (z.B. Qwen3) geben Antwort nach -Tag aus if idx := strings.LastIndex(raw, ""); idx >= 0 { raw = raw[idx+len(""):] } answer := strings.ToLower(strings.TrimSpace(raw)) isImportant := !strings.Contains(answer, "unwichtig") slog.Info("[Triage] Email klassifiziert", "betreff", msg.Subject, "von", msg.From, "wichtig", isImportant, "rag_beispiele", len(examples), "antwort", answer, ) // Entscheidung für künftiges Lernen in Qdrant speichern if err := triage.StoreDecision(msg.Subject, msg.From, isImportant); err != nil { slog.Warn("[Triage] Entscheidung nicht gespeichert", "fehler", err) } return isImportant } // ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines. func ExtractReminders() (string, error) { accounts := config.AllEmailAccounts() if len(accounts) == 0 { return "", fmt.Errorf("Kein Email-Account konfiguriert") } if len(accounts) == 1 { return fetchAndSummarizeAccount(accounts[0], 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.") } var parts []string for _, acc := range accounts { result, err := fetchAndSummarizeAccount(acc, 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.") if err != nil { slog.Error("Email-Fehler", "account", acc.Name, "fehler", err) parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) continue } parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result)) } return strings.Join(parts, "\n\n"), nil } // MoveUnread verschiebt alle ungelesenen Emails eines Accounts in den Zielordner. // Gibt die Anzahl verschobener Emails zurück. func MoveUnread(acc config.EmailAccount, destFolder string) (int, error) { cl, err := ConnectAccount(acc) if err != nil { return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err) } defer cl.Close() _, seqNums, err := cl.FetchUnreadSeqNums() if err != nil { return 0, fmt.Errorf("Emails abrufen: %w", err) } if len(seqNums) == 0 { return 0, nil } if err := cl.MoveMessages(seqNums, destFolder); err != nil { return 0, fmt.Errorf("Verschieben nach %s: %w", destFolder, err) } return len(seqNums), nil } // AccountSelectMessages enthält ungelesene Emails eines Accounts für die Discord-Auswahl. type AccountSelectMessages struct { Account config.EmailAccount AccIndex int Messages []SelectMessage } // FetchUnreadForSelectAllAccounts holt ungelesene Emails aller Accounts für die Discord-Auswahl. func FetchUnreadForSelectAllAccounts() ([]AccountSelectMessages, error) { accounts := config.AllEmailAccounts() var result []AccountSelectMessages for i, acc := range accounts { cl, err := ConnectAccount(acc) if err != nil { return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err) } msgs, err := cl.FetchUnreadForSelect() cl.Close() if err != nil { return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err) } result = append(result, AccountSelectMessages{ Account: acc, AccIndex: i, Messages: msgs, }) } return result, nil } // FetchRecentForSelectAllAccounts holt die letzten n Emails aller Accounts für die Discord-Auswahl. func FetchRecentForSelectAllAccounts(n uint32) ([]AccountSelectMessages, error) { accounts := config.AllEmailAccounts() var result []AccountSelectMessages for i, acc := range accounts { cl, err := ConnectAccount(acc) if err != nil { return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err) } msgs, err := cl.FetchRecentForSelect(n) cl.Close() if err != nil { return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err) } result = append(result, AccountSelectMessages{ Account: acc, AccIndex: i, Messages: msgs, }) } return result, nil } // MoveOldEmailsAllAccounts verschiebt alle Emails aller Accounts, die älter als olderThanDays Tage sind, nach destFolder. // Gibt die Gesamtanzahl verschobener Emails zurück. func MoveOldEmailsAllAccounts(destFolder string, olderThanDays int) (int, error) { accounts := config.AllEmailAccounts() total := 0 for _, acc := range accounts { cl, err := ConnectAccount(acc) if err != nil { return total, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err) } n, err := cl.MoveOldMessages(acc.Folder, destFolder, olderThanDays) cl.Close() if err != nil { return total, fmt.Errorf("Verschieben %s: %w", accountLabel(acc), err) } total += n } return total, nil } // MoveSpecificUnread verschiebt spezifische Emails (per Sequenznummer) eines Accounts in den Zielordner. func MoveSpecificUnread(accIndex int, seqNums []uint32, destFolder string) (int, error) { accounts := config.AllEmailAccounts() if accIndex < 0 || accIndex >= len(accounts) { return 0, fmt.Errorf("ungültiger Account-Index %d", accIndex) } acc := accounts[accIndex] cl, err := ConnectAccount(acc) if err != nil { return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err) } defer cl.Close() if err := cl.MoveSpecificMessages(seqNums, destFolder); err != nil { return 0, err } return len(seqNums), nil } // CleanupArchiveFolders löscht abgelaufene Emails aus allen konfigurierten Archivordnern. // Gibt eine menschenlesbare Zusammenfassung zurück. func CleanupArchiveFolders() (string, error) { accounts := config.AllEmailAccounts() var lines []string var errs []string total := 0 for _, acc := range accounts { for _, af := range acc.ArchiveFolders { if af.RetentionDays <= 0 { continue } cl, err := ConnectAccount(acc) if err != nil { errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err)) continue } n, err := cl.CleanupOldEmails(af.IMAPFolder, af.RetentionDays) cl.Close() if err != nil { errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err)) continue } if n > 0 { lines = append(lines, fmt.Sprintf("🗑️ %s/%s: %d Email(s) gelöscht (älter als %d Tage)", accountLabel(acc), af.Name, n, af.RetentionDays)) total += n } } } var result string if len(lines) == 0 && len(errs) == 0 { result = "Kein Aufräumen notwendig." } else { result = strings.Join(lines, "\n") } var combinedErr error if len(errs) > 0 { combinedErr = fmt.Errorf("%s", strings.Join(errs, "; ")) } slog.Info("Archiv-Aufräumen abgeschlossen", "gelöscht", total, "fehler", len(errs)) return result, combinedErr } // SummarizeMessages fasst eine übergebene Liste von Nachrichten zusammen (für Tests ohne IMAP). func SummarizeMessages(msgs []Message, instruction string) (string, error) { return summarizeWithLLM(msgs, instruction) } func fetchAndSummarizeAccount(acc config.EmailAccount, n uint32, instruction string) (string, error) { cl, err := ConnectAccount(acc) if err != nil { return "", fmt.Errorf("Email-Verbindung: %w", err) } defer cl.Close() msgs, err := cl.FetchRecent(n) if err != nil { return "", fmt.Errorf("Emails abrufen: %w", err) } if len(msgs) == 0 { return "📭 Keine Emails gefunden.", nil } slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs)) return summarizeWithLLMModel(msgs, instruction, accountModel(acc)) } // accountLabel gibt einen lesbaren Namen für einen Account zurück. func accountLabel(acc config.EmailAccount) string { if acc.Name != "" { return acc.Name } return acc.User } // accountModel gibt das konfigurierte LLM-Modell für einen Account zurück. func accountModel(acc config.EmailAccount) string { if acc.Model != "" { return acc.Model } return config.Cfg.Chat.Model } // emailModel gibt das konfigurierte Modell für den Legacy-Account zurück. func emailModel() string { if config.Cfg.Email.Model != "" { return config.Cfg.Email.Model } return config.Cfg.Chat.Model } // formatEmailList formatiert Emails als lesbaren Text (Fallback und Eingabe fürs LLM). func formatEmailList(msgs []Message) string { var sb strings.Builder for i, m := range msgs { fmt.Fprintf(&sb, "[%d] Von: %s | Datum: %s | Betreff: %s\n", i+1, m.From, m.Date, m.Subject) } return sb.String() } func summarizeWithLLM(msgs []Message, instruction string) (string, error) { return summarizeWithLLMModel(msgs, instruction, emailModel()) } func summarizeWithLLMModel(msgs []Message, instruction, model string) (string, error) { emailList := formatEmailList(msgs) chatClient := config.NewChatClient() ctx := context.Background() systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Analysiere Email-Listen und antworte auf Deutsch, präzise und strukturiert.` userPrompt := fmt.Sprintf("%s\n\nEmail-Liste:\n%s", instruction, emailList) slog.Debug("[LLM] Email Prompt", "model", model, "emails", len(msgs), "system", systemPrompt, "user", userPrompt, ) start := time.Now() stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ Model: model, Messages: []openai.ChatCompletionMessage{ {Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, {Role: openai.ChatMessageRoleUser, Content: userPrompt}, }, Temperature: 0.5, MaxTokens: 600, }) if err != nil { slog.Warn("[LLM] nicht erreichbar, Fallback-Liste", "fehler", err) return fallbackList(msgs), nil } defer stream.Close() var answer strings.Builder for { resp, err := stream.Recv() if err != nil { break } if len(resp.Choices) > 0 { answer.WriteString(resp.Choices[0].Delta.Content) } } result := answer.String() slog.Debug("[LLM] Email Antwort", "dauer", time.Since(start).Round(time.Millisecond), "zeichen", len(result), "antwort", result, ) if strings.TrimSpace(result) == "" { slog.Warn("[LLM] leere Antwort, Fallback-Liste") return fallbackList(msgs), nil } slog.Info("[LLM] Email-Zusammenfassung abgeschlossen", "dauer", time.Since(start).Round(time.Millisecond), "zeichen", len(result)) return result, nil } // fallbackList gibt eine einfache formatierte Liste zurück wenn das LLM nicht verfügbar ist. func fallbackList(msgs []Message) string { var sb strings.Builder sb.WriteString("⚠️ *LLM nicht verfügbar – ungefilterte Email-Liste:*\n\n") for i, m := range msgs { fmt.Fprintf(&sb, "**[%d]** %s\n📤 %s\n📌 %s\n\n", i+1, m.Date, m.From, m.Subject) } return sb.String() }