181 lines
5.3 KiB
Go
181 lines
5.3 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"
|
||
)
|
||
|
||
// Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen.
|
||
func Summarize() (string, error) {
|
||
return fetchAndSummarize(20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
|
||
}
|
||
|
||
// SummarizeUnread fasst ungelesene Emails zusammen.
|
||
// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben.
|
||
func SummarizeUnread() (string, error) {
|
||
cl, err := Connect()
|
||
if err != nil {
|
||
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||
}
|
||
defer cl.Close()
|
||
|
||
processedFolder := config.Cfg.Email.ProcessedFolder
|
||
|
||
var msgs []Message
|
||
var seqNums []uint32
|
||
|
||
if processedFolder != "" {
|
||
msgs, seqNums, err = cl.FetchUnreadSeqNums()
|
||
} else {
|
||
msgs, err = cl.FetchUnread()
|
||
}
|
||
if err != nil {
|
||
return "", fmt.Errorf("Emails abrufen: %w", err)
|
||
}
|
||
if len(msgs) == 0 {
|
||
return "📭 Keine ungelesenen Emails.", nil
|
||
}
|
||
|
||
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread")
|
||
result, err := summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben
|
||
if processedFolder != "" && len(seqNums) > 0 {
|
||
if moveErr := cl.MoveMessages(seqNums, processedFolder); moveErr != nil {
|
||
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", processedFolder)
|
||
} else {
|
||
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", processedFolder)
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines.
|
||
func ExtractReminders() (string, error) {
|
||
return fetchAndSummarize(30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
|
||
}
|
||
|
||
// 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 fetchAndSummarize(n uint32, instruction string) (string, error) {
|
||
cl, err := Connect()
|
||
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", "anzahl", len(msgs))
|
||
return summarizeWithLLM(msgs, instruction)
|
||
}
|
||
|
||
// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück.
|
||
// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist.
|
||
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) {
|
||
emailList := formatEmailList(msgs)
|
||
model := emailModel()
|
||
|
||
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()
|
||
}
|