Files
ai-agent/internal/agents/tool/agent.go
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

273 lines
8.5 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// tool/agent.go Tool-Agent: Dispatcher für externe Tools (Email, ...)
package tool
import (
"fmt"
"strconv"
"strings"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/brain"
"my-brain-importer/internal/config"
"my-brain-importer/internal/triage"
)
// Agent verteilt Tool-Anfragen an spezialisierte Sub-Agenten.
type Agent struct{}
func New() *Agent { return &Agent{} }
// Handle unterstützt: email
func (a *Agent) Handle(req agents.Request) agents.Response {
switch req.Action {
case agents.ActionEmail:
return a.handleEmail(req)
default:
return agents.Response{Text: "❌ Unbekannte Tool-Aktion. Verfügbar: email"}
}
}
func (a *Agent) handleEmail(req agents.Request) agents.Response {
subAction := agents.ActionEmailSummary
if len(req.Args) > 0 {
subAction = req.Args[0]
}
var (
result string
err error
)
switch subAction {
case agents.ActionEmailSummary:
result, err = email.Summarize()
case agents.ActionEmailUnread:
result, err = email.SummarizeUnread()
case agents.ActionEmailRemind:
result, err = email.ExtractReminders()
case agents.ActionEmailIngest:
return a.handleEmailIngest(req)
case agents.ActionEmailMove:
return a.handleEmailMove(req)
case agents.ActionEmailTriage:
return a.handleEmailTriage()
case agents.ActionEmailTriageHistory:
return a.handleTriageHistory(req)
case agents.ActionEmailTriageCorrect:
return a.handleTriageCorrect(req)
case agents.ActionEmailTriageSearch:
return a.handleTriageSearch(req)
default:
return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind, ingest, move, triage, triage-history, triage-correct, triage-search", subAction)}
}
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Email-Fehler: %v", err)}
}
return agents.Response{Text: "📧 **Email-Analyse:**\n\n" + result}
}
// handleEmailIngest importiert Emails aus einem IMAP-Ordner in Qdrant.
// Args[1] = Ordnername (Standard: "Archiv")
func (a *Agent) handleEmailIngest(req agents.Request) agents.Response {
folder := "Archiv"
if len(req.Args) > 1 && req.Args[1] != "" {
folder = req.Args[1]
}
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return agents.Response{Text: "❌ Kein Email-Account konfiguriert."}
}
total := 0
var errs []string
for _, acc := range accounts {
n, err := brain.IngestEmailFolder(acc, folder, 500)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", acc.Name, err))
continue
}
total += n
}
if len(errs) > 0 && total == 0 {
return agents.Response{Text: fmt.Sprintf("❌ Email-Ingest fehlgeschlagen:\n%s", joinLines(errs))}
}
msg := fmt.Sprintf("✅ **Email-Ingest abgeschlossen:** %d Emails aus `%s` in die Wissensdatenbank importiert.", total, folder)
if len(errs) > 0 {
msg += "\n⚠ Fehler bei einigen Accounts:\n" + joinLines(errs)
}
return agents.Response{Text: msg}
}
// handleEmailMove verschiebt alle ungelesenen Emails in einen konfigurierten Archivordner.
// Args[1] = Zielordner-Name (aus archive_folders in config oder Legacy: 2Jahre/5Jahre/Archiv)
func (a *Agent) handleEmailMove(req agents.Request) agents.Response {
if len(req.Args) < 2 || req.Args[1] == "" {
return agents.Response{Text: "❌ Zielordner fehlt. " + buildMoveFoldersHint()}
}
dest := req.Args[1]
imapFolder, ok := resolveArchiveFolder(dest)
if !ok {
return agents.Response{Text: fmt.Sprintf("❌ Unbekannter Ordner `%s`. %s", dest, buildMoveFoldersHint())}
}
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return agents.Response{Text: "❌ Kein Email-Account konfiguriert."}
}
total := 0
var errs []string
for _, acc := range accounts {
n, err := email.MoveUnread(acc, imapFolder)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", acc.Name, err))
continue
}
total += n
}
if len(errs) > 0 && total == 0 {
return agents.Response{Text: fmt.Sprintf("❌ Verschieben fehlgeschlagen:\n%s", joinLines(errs))}
}
if total == 0 {
return agents.Response{Text: fmt.Sprintf("📭 Keine ungelesenen Emails zum Verschieben nach `%s`.", imapFolder)}
}
msg := fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", total, imapFolder)
if len(errs) > 0 {
msg += "\n⚠ Fehler:\n" + joinLines(errs)
}
return agents.Response{Text: msg}
}
// handleEmailTriage klassifiziert die letzten 10 Emails aller Accounts und verschiebt sie.
func (a *Agent) handleEmailTriage() agents.Response {
result, err := email.TriageRecentAllAccounts(10)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Triage fehlgeschlagen: %v", err)}
}
return agents.Response{Text: "🗂️ **Email-Triage (letzte 10 Emails):**\n\n" + result}
}
// handleTriageHistory zeigt die letzten N Triage-Entscheidungen.
func (a *Agent) handleTriageHistory(req agents.Request) agents.Response {
limit := uint32(10)
if len(req.Args) > 1 && req.Args[1] != "" {
if n, err := strconv.ParseUint(req.Args[1], 10, 32); err == nil && n > 0 {
limit = uint32(n)
}
}
results, err := triage.ListRecent(limit)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Triage-History fehlgeschlagen: %v", err)}
}
if len(results) == 0 {
return agents.Response{Text: "📭 Keine Triage-Entscheidungen gespeichert."}
}
var sb strings.Builder
fmt.Fprintf(&sb, "🗂️ **Triage-History (%d Einträge):**\n\n", len(results))
for i, r := range results {
fmt.Fprintf(&sb, "**%d.** %s\n", i+1, r.Text)
}
return agents.Response{Text: sb.String()}
}
// handleTriageCorrect korrigiert eine Triage-Entscheidung (wichtig↔unwichtig).
func (a *Agent) handleTriageCorrect(req agents.Request) agents.Response {
if len(req.Args) < 2 || req.Args[1] == "" {
return agents.Response{Text: "❌ Betreff fehlt. Beispiel: `/email triage-correct Newsletter`"}
}
query := strings.Join(req.Args[1:], " ")
msg, err := triage.CorrectDecision(query)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Korrektur fehlgeschlagen: %v", err)}
}
return agents.Response{Text: "✅ " + msg}
}
// handleTriageSearch sucht ähnliche Triage-Entscheidungen.
func (a *Agent) handleTriageSearch(req agents.Request) agents.Response {
if len(req.Args) < 2 || req.Args[1] == "" {
return agents.Response{Text: "❌ Suchbegriff fehlt. Beispiel: `/email triage-search Newsletter`"}
}
query := strings.Join(req.Args[1:], " ")
results, err := triage.SearchExtended(query, 10)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Triage-Suche fehlgeschlagen: %v", err)}
}
if len(results) == 0 {
return agents.Response{Text: "📭 Keine ähnlichen Triage-Entscheidungen gefunden."}
}
var sb strings.Builder
fmt.Fprintf(&sb, "🔍 **Triage-Suche** (query: `%s`, %d Treffer):\n\n", query, len(results))
for i, r := range results {
fmt.Fprintf(&sb, "**%d.** [%.0f%%] %s\n", i+1, r.Score*100, r.Text)
}
return agents.Response{Text: sb.String()}
}
// ResolveArchiveFolder ist die exportierte Version von resolveArchiveFolder für den Discord-Layer.
func ResolveArchiveFolder(name string) (imapFolder string, ok bool) {
return resolveArchiveFolder(name)
}
// resolveArchiveFolder sucht den IMAP-Ordnernamen für einen Anzeigenamen aus der Config.
// Fallback: Legacy-Hardcoding für 2Jahre/5Jahre/Archiv wenn keine archive_folders konfiguriert.
func resolveArchiveFolder(name string) (imapFolder string, ok bool) {
for _, acc := range config.AllEmailAccounts() {
for _, af := range acc.ArchiveFolders {
if strings.EqualFold(af.Name, name) || strings.EqualFold(af.IMAPFolder, name) {
return af.IMAPFolder, true
}
}
}
// Legacy-Fallback für Konfigurationen ohne archive_folders
legacy := map[string]string{
"2jahre": "2Jahre",
"5jahre": "5Jahre",
"archiv": "Archiv",
}
if canonical, found := legacy[strings.ToLower(name)]; found {
return canonical, true
}
return "", false
}
// buildMoveFoldersHint gibt eine Hinweis-Nachricht mit verfügbaren Archivordnern zurück.
func buildMoveFoldersHint() string {
seen := map[string]bool{}
var names []string
for _, acc := range config.AllEmailAccounts() {
for _, af := range acc.ArchiveFolders {
key := strings.ToLower(af.Name)
if !seen[key] {
seen[key] = true
names = append(names, fmt.Sprintf("`%s`", af.Name))
}
}
}
if len(names) == 0 {
return "Verfügbar: `2Jahre`, `5Jahre`, `Archiv`"
}
return fmt.Sprintf("Verfügbar: %s", strings.Join(names, ", "))
}
func joinLines(lines []string) string {
result := ""
for _, l := range lines {
result += "• " + l + "\n"
}
return result
}