Files
Christoph K. b6b451779d Email-Triage: Lernen aus IMAP-Ordnern, manuelle Korrektur, reichere Daten
- 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>
2026-03-21 14:13:55 +01:00

711 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}