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>
This commit is contained in:
Christoph K.
2026-03-21 14:13:55 +01:00
parent 905981cd1e
commit b6b451779d
6 changed files with 695 additions and 14 deletions

View File

@@ -3,12 +3,14 @@ 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.
@@ -50,8 +52,14 @@ func (a *Agent) handleEmail(req agents.Request) agents.Response {
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", subAction)}
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 {
@@ -147,6 +155,68 @@ func (a *Agent) handleEmailTriage() agents.Response {
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)