- 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>
273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
// 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
|
||
}
|