llm mail integration
This commit is contained in:
23
internal/agents/actions.go
Normal file
23
internal/agents/actions.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// actions.go – Typsichere Konstanten für Agent-Actions
|
||||
package agents
|
||||
|
||||
const (
|
||||
// Research
|
||||
ActionQuery = "query"
|
||||
|
||||
// Memory
|
||||
ActionStore = "store"
|
||||
ActionIngest = "ingest"
|
||||
|
||||
// Task
|
||||
ActionAdd = "add"
|
||||
ActionList = "list"
|
||||
ActionDone = "done"
|
||||
ActionDelete = "delete"
|
||||
|
||||
// Tool/Email
|
||||
ActionEmail = "email"
|
||||
ActionEmailSummary = "summary"
|
||||
ActionEmailUnread = "unread"
|
||||
ActionEmailRemind = "remind"
|
||||
)
|
||||
21
internal/agents/agent.go
Normal file
21
internal/agents/agent.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// agent.go – Gemeinsames Interface für alle Agenten
|
||||
package agents
|
||||
|
||||
// Request enthält die Eingabe für einen Agenten.
|
||||
type Request struct {
|
||||
Action string // z.B. "store", "list", "done", "summary"
|
||||
Args []string // Argumente für die Aktion
|
||||
Author string // Discord-Username (für Kontext)
|
||||
Source string // Herkunft (z.B. "discord/#channelID")
|
||||
}
|
||||
|
||||
// Response enthält die Ausgabe eines Agenten.
|
||||
type Response struct {
|
||||
Text string // Formattierte Antwort
|
||||
Error error // Fehler, falls aufgetreten
|
||||
}
|
||||
|
||||
// Agent ist das gemeinsame Interface für alle Agenten.
|
||||
type Agent interface {
|
||||
Handle(req Request) Response
|
||||
}
|
||||
56
internal/agents/memory/agent.go
Normal file
56
internal/agents/memory/agent.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// memory/agent.go – Memory-Agent: wraps brain.RunIngest und brain.IngestChatMessage
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"my-brain-importer/internal/agents"
|
||||
"my-brain-importer/internal/brain"
|
||||
"my-brain-importer/internal/config"
|
||||
)
|
||||
|
||||
// Agent verwaltet das Einspeichern von Wissen.
|
||||
type Agent struct{}
|
||||
|
||||
func New() *Agent { return &Agent{} }
|
||||
|
||||
// Handle unterstützt zwei Aktionen:
|
||||
// - "store": Speichert Text als Chat-Nachricht
|
||||
// - "ingest": Startet den Markdown-Ingest aus brain_root
|
||||
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||
switch req.Action {
|
||||
case agents.ActionStore:
|
||||
return a.store(req)
|
||||
case agents.ActionIngest:
|
||||
return a.ingest(req)
|
||||
default:
|
||||
return agents.Response{Text: "❌ Unbekannte Memory-Aktion. Verfügbar: store, ingest"}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) store(req agents.Request) agents.Response {
|
||||
if len(req.Args) == 0 {
|
||||
return agents.Response{Text: "❌ Kein Text zum Speichern angegeben."}
|
||||
}
|
||||
text := strings.Join(req.Args, " ")
|
||||
author := req.Author
|
||||
if author == "" {
|
||||
author = "unknown"
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "agent"
|
||||
}
|
||||
|
||||
err := brain.IngestChatMessage(text, author, source)
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim Speichern: %v", err)}
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("✅ Gespeichert: _%s_", text)}
|
||||
}
|
||||
|
||||
func (a *Agent) ingest(_ agents.Request) agents.Response {
|
||||
brain.RunIngest(config.Cfg.BrainRoot)
|
||||
return agents.Response{Text: fmt.Sprintf("✅ Ingest abgeschlossen! Quelle: `%s`", config.Cfg.BrainRoot)}
|
||||
}
|
||||
39
internal/agents/research/agent.go
Normal file
39
internal/agents/research/agent.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// research/agent.go – Research-Agent: wraps brain.AskQuery
|
||||
package research
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"my-brain-importer/internal/agents"
|
||||
"my-brain-importer/internal/brain"
|
||||
)
|
||||
|
||||
// Agent beantwortet Fragen mit der Wissensdatenbank.
|
||||
type Agent struct{}
|
||||
|
||||
func New() *Agent { return &Agent{} }
|
||||
|
||||
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||
if len(req.Args) == 0 {
|
||||
return agents.Response{Text: "❌ Keine Frage angegeben."}
|
||||
}
|
||||
question := strings.Join(req.Args, " ")
|
||||
|
||||
answer, chunks, err := brain.AskQuery(question)
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return agents.Response{Text: "❌ Keine relevanten Informationen in der Datenbank gefunden.\nFüge mehr Daten mit `/ingest` hinzu."}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "💬 **Antwort auf:** _%s_\n\n", question)
|
||||
sb.WriteString(answer)
|
||||
sb.WriteString("\n\n📚 **Quellen:**\n")
|
||||
for _, chunk := range chunks {
|
||||
fmt.Fprintf(&sb, "• %.1f%% – %s\n", chunk.Score*100, chunk.Source)
|
||||
}
|
||||
return agents.Response{Text: sb.String()}
|
||||
}
|
||||
133
internal/agents/task/agent.go
Normal file
133
internal/agents/task/agent.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// task/agent.go – Task-Agent: add/list/done/delete
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"my-brain-importer/internal/agents"
|
||||
)
|
||||
|
||||
// Agent verwaltet Aufgaben über tasks.json.
|
||||
type Agent struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// New erstellt einen neuen Task-Agenten.
|
||||
func New() *Agent {
|
||||
return &Agent{store: NewStore()}
|
||||
}
|
||||
|
||||
// Handle unterstützt: add, list, done, delete
|
||||
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||
switch req.Action {
|
||||
case agents.ActionAdd:
|
||||
return a.add(req)
|
||||
case agents.ActionList:
|
||||
return a.list()
|
||||
case agents.ActionDone:
|
||||
return a.done(req)
|
||||
case agents.ActionDelete:
|
||||
return a.del(req)
|
||||
default:
|
||||
return agents.Response{Text: "❌ Unbekannte Task-Aktion. Verfügbar: add, list, done, delete"}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) add(req agents.Request) agents.Response {
|
||||
if len(req.Args) == 0 {
|
||||
return agents.Response{Text: "❌ Kein Task-Text angegeben."}
|
||||
}
|
||||
text := strings.Join(req.Args, " ")
|
||||
t, err := a.store.Add(text)
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
shortID := t.ID
|
||||
if len(shortID) > 6 {
|
||||
shortID = shortID[len(shortID)-6:]
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("✅ Task hinzugefügt: `%s` (ID: `%s`)", t.Text, shortID)}
|
||||
}
|
||||
|
||||
func (a *Agent) list() agents.Response {
|
||||
tasks, err := a.store.Load()
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return agents.Response{Text: "📋 Keine Tasks vorhanden."}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📋 **Task-Liste:**\n\n")
|
||||
openCount := 0
|
||||
for _, t := range tasks {
|
||||
status := "⬜"
|
||||
if t.Done {
|
||||
status = "✅"
|
||||
} else {
|
||||
openCount++
|
||||
}
|
||||
shortID := t.ID
|
||||
if len(shortID) > 6 {
|
||||
shortID = shortID[len(shortID)-6:]
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s `%s` – %s\n", status, shortID, t.Text)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n*%d offen, %d gesamt*", openCount, len(tasks))
|
||||
return agents.Response{Text: sb.String()}
|
||||
}
|
||||
|
||||
func (a *Agent) done(req agents.Request) agents.Response {
|
||||
if len(req.Args) == 0 {
|
||||
return agents.Response{Text: "❌ Keine Task-ID angegeben."}
|
||||
}
|
||||
id := req.Args[0]
|
||||
tasks, err := a.store.Load()
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
fullID := resolveID(tasks, id)
|
||||
if fullID == "" {
|
||||
return agents.Response{Text: fmt.Sprintf("❌ Task `%s` nicht gefunden.", id)}
|
||||
}
|
||||
if err := a.store.MarkDone(fullID); err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("✅ Task `%s` als erledigt markiert.", id)}
|
||||
}
|
||||
|
||||
func (a *Agent) del(req agents.Request) agents.Response {
|
||||
if len(req.Args) == 0 {
|
||||
return agents.Response{Text: "❌ Keine Task-ID angegeben."}
|
||||
}
|
||||
id := req.Args[0]
|
||||
tasks, err := a.store.Load()
|
||||
if err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
fullID := resolveID(tasks, id)
|
||||
if fullID == "" {
|
||||
return agents.Response{Text: fmt.Sprintf("❌ Task `%s` nicht gefunden.", id)}
|
||||
}
|
||||
if err := a.store.Delete(fullID); err != nil {
|
||||
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||
}
|
||||
return agents.Response{Text: fmt.Sprintf("🗑️ Task `%s` gelöscht.", id)}
|
||||
}
|
||||
|
||||
// resolveID findet eine vollständige ID aus einer vollständigen oder kurzen (letzte 6 Zeichen).
|
||||
func resolveID(tasks []Task, id string) string {
|
||||
for _, t := range tasks {
|
||||
if t.ID == id {
|
||||
return t.ID
|
||||
}
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if len(t.ID) >= 6 && t.ID[len(t.ID)-6:] == id {
|
||||
return t.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
172
internal/agents/task/store.go
Normal file
172
internal/agents/task/store.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// task/store.go – JSON-Persistenz für Tasks (atomisches Schreiben)
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"my-brain-importer/internal/config"
|
||||
)
|
||||
|
||||
// Task repräsentiert eine Aufgabe.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Done bool `json:"done"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DoneAt *time.Time `json:"done_at,omitempty"`
|
||||
}
|
||||
|
||||
// Store verwaltet tasks.json mit atomischen Schreiboperationen.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewStore erstellt einen Store mit dem Pfad aus der Config.
|
||||
func NewStore() *Store {
|
||||
path := config.Cfg.Tasks.StorePath
|
||||
if path == "" {
|
||||
path = "./tasks.json"
|
||||
}
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
// Load liest alle Tasks aus der JSON-Datei.
|
||||
func (s *Store) Load() ([]Task, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return []Task{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tasks lesen: %w", err)
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||
return nil, fmt.Errorf("tasks parsen: %w", err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// save schreibt alle Tasks atomisch. Muss unter mu aufgerufen werden.
|
||||
func (s *Store) save(tasks []Task) error {
|
||||
data, err := json.MarshalIndent(tasks, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tasks serialisieren: %w", err)
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return fmt.Errorf("temp-datei schreiben: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// Add fügt einen neuen Task hinzu.
|
||||
func (s *Store) Add(text string) (Task, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tasks, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
|
||||
t := Task{
|
||||
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
Text: text,
|
||||
Done: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
|
||||
if err := s.save(tasks); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// MarkDone markiert einen Task als erledigt.
|
||||
func (s *Store) MarkDone(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tasks, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
now := time.Now()
|
||||
for i, t := range tasks {
|
||||
if t.ID == id {
|
||||
tasks[i].Done = true
|
||||
tasks[i].DoneAt = &now
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("task %q nicht gefunden", id)
|
||||
}
|
||||
return s.save(tasks)
|
||||
}
|
||||
|
||||
// Delete löscht einen Task.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tasks, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTasks := tasks[:0]
|
||||
for _, t := range tasks {
|
||||
if t.ID != id {
|
||||
newTasks = append(newTasks, t)
|
||||
}
|
||||
}
|
||||
if len(newTasks) == len(tasks) {
|
||||
return fmt.Errorf("task %q nicht gefunden", id)
|
||||
}
|
||||
return s.save(newTasks)
|
||||
}
|
||||
|
||||
// OpenTasks gibt alle offenen Tasks zurück.
|
||||
func (s *Store) OpenTasks() ([]Task, error) {
|
||||
tasks, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var open []Task
|
||||
for _, t := range tasks {
|
||||
if !t.Done {
|
||||
open = append(open, t)
|
||||
}
|
||||
}
|
||||
return open, nil
|
||||
}
|
||||
|
||||
// loadLocked liest ohne eigenes Lock (muss unter s.mu aufgerufen werden).
|
||||
func (s *Store) loadLocked() ([]Task, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return []Task{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tasks lesen: %w", err)
|
||||
}
|
||||
var tasks []Task
|
||||
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||
return nil, fmt.Errorf("tasks parsen: %w", err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
52
internal/agents/tool/agent.go
Normal file
52
internal/agents/tool/agent.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// tool/agent.go – Tool-Agent: Dispatcher für externe Tools (Email, ...)
|
||||
package tool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"my-brain-importer/internal/agents"
|
||||
"my-brain-importer/internal/agents/tool/email"
|
||||
)
|
||||
|
||||
// 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()
|
||||
default:
|
||||
return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind", 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}
|
||||
}
|
||||
150
internal/agents/tool/email/client.go
Normal file
150
internal/agents/tool/email/client.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// email/client.go – IMAP-Client für Email-Abfragen
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
|
||||
imap "github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
|
||||
"my-brain-importer/internal/config"
|
||||
)
|
||||
|
||||
// Message repräsentiert eine Email (ohne Body für schnelle Übersichten).
|
||||
type Message struct {
|
||||
Subject string
|
||||
From string
|
||||
Date string
|
||||
}
|
||||
|
||||
// Client wraps die IMAP-Verbindung.
|
||||
type Client struct {
|
||||
c *imapclient.Client
|
||||
}
|
||||
|
||||
// Connect öffnet eine IMAP-Verbindung.
|
||||
func Connect() (*Client, error) {
|
||||
cfg := config.Cfg.Email
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
var (
|
||||
c *imapclient.Client
|
||||
err error
|
||||
)
|
||||
|
||||
switch {
|
||||
case cfg.TLS:
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||
case cfg.StartTLS:
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||
default:
|
||||
c, err = imapclient.DialInsecure(addr, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP verbinden: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("IMAP login: %w", err)
|
||||
}
|
||||
|
||||
return &Client{c: c}, nil
|
||||
}
|
||||
|
||||
// Close schließt die Verbindung.
|
||||
func (cl *Client) Close() {
|
||||
cl.c.Logout().Wait()
|
||||
cl.c.Close()
|
||||
}
|
||||
|
||||
// FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body).
|
||||
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
||||
folder := config.Cfg.Email.Folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
selectData, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP select: %w", err)
|
||||
}
|
||||
if selectData.NumMessages == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
start := uint32(1)
|
||||
if selectData.NumMessages > n {
|
||||
start = selectData.NumMessages - n + 1
|
||||
}
|
||||
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddRange(start, selectData.NumMessages)
|
||||
|
||||
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||
}
|
||||
|
||||
return parseMessages(msgs), nil
|
||||
}
|
||||
|
||||
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
|
||||
func (cl *Client) FetchUnread() ([]Message, error) {
|
||||
folder := config.Cfg.Email.Folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
if _, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||
return nil, fmt.Errorf("IMAP select: %w", err)
|
||||
}
|
||||
|
||||
searchData, err := cl.c.Search(&imap.SearchCriteria{
|
||||
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||
}, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP search: %w", err)
|
||||
}
|
||||
|
||||
seqNums := searchData.AllSeqNums()
|
||||
if len(seqNums) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddNum(seqNums...)
|
||||
|
||||
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||
}
|
||||
|
||||
return parseMessages(msgs), nil
|
||||
}
|
||||
|
||||
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
|
||||
result := make([]Message, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
m := Message{
|
||||
Subject: msg.Envelope.Subject,
|
||||
Date: msg.Envelope.Date.Format("2006-01-02 15:04"),
|
||||
}
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
addr := msg.Envelope.From[0]
|
||||
if addr.Name != "" {
|
||||
m.From = fmt.Sprintf("%s <%s@%s>", addr.Name, addr.Mailbox, addr.Host)
|
||||
} else {
|
||||
m.From = fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
|
||||
}
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
156
internal/agents/tool/email/summary.go
Normal file
156
internal/agents/tool/email/summary.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// 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.
|
||||
func SummarizeUnread() (string, error) {
|
||||
cl, err := Connect()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
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")
|
||||
return summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user