- Automatisches Triage-Lernen aus Archiv-Ordnern im Nacht-Ingest: retention_days=0 (Archiv) → wichtig, retention_days>0 → unwichtig - Drei neue Discord-Commands: /email triage-history, triage-correct, triage-search - StoreDecision speichert jetzt Datum + Body-Zusammenfassung (max 200 Zeichen) - MIME-Multipart-Parsing mit PDF-Attachment-Extraktion (FetchWithBodyAndAttachments) - Deterministische IDs basierend auf Absender+Betreff (idempotente Upserts) - Rueckwaertskompatibles Parsing fuer alte Triage-Eintraege ohne Datum/Body Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
711 lines
23 KiB
Go
711 lines
23 KiB
Go
// 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 </think>-Tag aus
|
||
if idx := strings.LastIndex(raw, "</think>"); idx >= 0 {
|
||
raw = raw[idx+len("</think>"):]
|
||
}
|
||
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
|
||
// Body ist bei ClassifyImportance nicht verfügbar (nur Envelope), daher leer.
|
||
if err := triage.StoreDecision(msg.Subject, msg.From, msg.Date, "", 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()
|
||
}
|
||
|
||
// LearnFromFolders scannt die Archiv-Ordner eines Accounts und speichert Triage-Entscheidungen in Qdrant.
|
||
// Ordner mit retention_days == 0 (Archiv/dauerhaft) → wichtig, retention_days > 0 → unwichtig.
|
||
// Pro Ordner werden maximal die letzten 50 Emails verarbeitet.
|
||
func LearnFromFolders(acc config.EmailAccount) (wichtig, unwichtig int, err error) {
|
||
if len(acc.ArchiveFolders) == 0 {
|
||
return 0, 0, nil
|
||
}
|
||
|
||
cl, err := ConnectAccount(acc)
|
||
if err != nil {
|
||
return 0, 0, fmt.Errorf("IMAP verbinden: %w", err)
|
||
}
|
||
defer cl.Close()
|
||
|
||
for _, af := range acc.ArchiveFolders {
|
||
isImportant := af.RetentionDays == 0
|
||
|
||
msgs, err := cl.FetchWithBodyAndAttachments(af.IMAPFolder, 50)
|
||
if err != nil {
|
||
slog.Warn("[Triage-Learn] Ordner nicht lesbar", "ordner", af.IMAPFolder, "fehler", err)
|
||
continue
|
||
}
|
||
|
||
for _, m := range msgs {
|
||
if err := triage.StoreDecision(m.Subject, m.From, m.Date, m.Body, isImportant); err != nil {
|
||
slog.Warn("[Triage-Learn] Speichern fehlgeschlagen", "betreff", m.Subject, "fehler", err)
|
||
continue
|
||
}
|
||
if isImportant {
|
||
wichtig++
|
||
} else {
|
||
unwichtig++
|
||
}
|
||
}
|
||
|
||
slog.Info("[Triage-Learn] Ordner verarbeitet",
|
||
"account", accountLabel(acc),
|
||
"ordner", af.IMAPFolder,
|
||
"emails", len(msgs),
|
||
"wichtig", isImportant,
|
||
)
|
||
}
|
||
|
||
return wichtig, unwichtig, nil
|
||
}
|
||
|
||
// LearnFromFoldersAllAccounts führt LearnFromFolders für alle konfigurierten Accounts aus.
|
||
func LearnFromFoldersAllAccounts() (wichtig, unwichtig int, err error) {
|
||
accounts := config.AllEmailAccounts()
|
||
for _, acc := range accounts {
|
||
w, u, accErr := LearnFromFolders(acc)
|
||
if accErr != nil {
|
||
slog.Error("[Triage-Learn] Account fehlgeschlagen", "account", accountLabel(acc), "fehler", accErr)
|
||
continue
|
||
}
|
||
wichtig += w
|
||
unwichtig += u
|
||
}
|
||
return wichtig, unwichtig, nil
|
||
}
|