llm mail integration

This commit is contained in:
Christoph K.
2026-03-19 21:46:12 +01:00
parent fdc7a8588d
commit 0e7aa3e7f2
19 changed files with 1707 additions and 306 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**my-brain-importer** is a personal RAG (Retrieval-Augmented Generation) system written in Go. It ingests Markdown notes and image descriptions into a Qdrant vector database and answers questions using a local LLM via LocalAI. **my-brain-importer** is a personal AI assistant and RAG (Retrieval-Augmented Generation) system written in Go. It ingests Markdown notes into a Qdrant vector database, answers questions using a local LLM (LocalAI), and is primarily controlled via Discord. A background daemon sends proactive email summaries and task reminders.
## Commands ## Commands
@@ -12,13 +12,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Build all binaries (Linux + Windows cross-compile) # Build all binaries (Linux + Windows cross-compile)
bash build.sh bash build.sh
# Run directly without building # Primary entry point: Discord Bot (includes daemon)
go run ./cmd/ingest/ go run ./cmd/discord/
go run ./cmd/ask/ "your question here"
# Build individual binaries # CLI tools
go build ./cmd/ingest/ go run ./cmd/ingest/ # Markdown importieren
go build ./cmd/ask/ go run ./cmd/ingest/ bild.json # JSON importieren
go run ./cmd/ask/ "your question here" # Frage stellen
# Test: IMAP-Verbindung
go run ./cmd/mailtest/
# Test: LLM-Zusammenfassung ohne IMAP
go run ./cmd/mailtest/ -llm-only
# Run tests # Run tests
go test ./... go test ./...
@@ -31,20 +37,80 @@ Binaries are output to `./bin/`. The `config.yml` file must exist in the working
## Architecture ## Architecture
Two CLI tools share a common internal library: ```
Discord (primäres Interface)
↓ Slash-Commands + @Mention
cmd/discord/main.go
├── internal/agents/research/ → brain.AskQuery()
├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage()
├── internal/agents/task/ → tasks.json (atomisches JSON)
└── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung
[Daemon-Goroutine] startDaemon()
├── Email-Check (alle N min) → #localagent Discord-Channel
└── Task-Reminder (täglich) → #localagent Discord-Channel
**`cmd/ingest/`** → `internal/brain/ingest.go` + `internal/brain/ingest_json.go` cmd/ingest/ + cmd/ask/ (CLI-Tools, direkt nutzbar)
- Markdown mode: recursively finds `.md` files, splits by `# `/`## ` headings, chunks long sections (max 800 chars) by paragraphs, embeds in batches of 10, upserts to Qdrant
- JSON mode (when arg ends in `.json`): imports image description records with `file_path`, `file_name`, `description` fields internal/brain/ (Core RAG-Logik, unverändert)
Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
```
**`cmd/ask/`** → `internal/brain/ask.go` ### Packages
- Embeds the question, searches Qdrant (top-k, score threshold 0.5), deduplicates by text content, streams LLM response constrained to retrieved context
**`internal/config/config.go`** initializes all clients: gRPC connection to Qdrant and OpenAI-compatible HTTP clients for embeddings and chat (both point to LocalAI). | Package | Zweck |
|---------|-------|
| `cmd/discord/` | Discord Bot + Daemon (primärer Einstiegspunkt) |
| `cmd/ask/` | CLI-Tool: Fragen stellen |
| `cmd/ingest/` | CLI-Tool: Markdown/JSON importieren |
| `cmd/mailtest/` | Testprogramm: IMAP + LLM-Test |
| `internal/brain/` | Core RAG: Embeddings, Qdrant-Suche, LLM-Streaming |
| `internal/config/` | Konfiguration + Client-Initialisierung (globale `Cfg`) |
| `internal/agents/` | Agent-Interface (`Request`/`Response`) |
| `internal/agents/research/` | Research-Agent: Wissensdatenbank-Abfragen |
| `internal/agents/memory/` | Memory-Agent: Ingest + Chat-Speicherung |
| `internal/agents/task/` | Task-Agent: Aufgabenverwaltung (tasks.json) |
| `internal/agents/tool/` | Tool-Dispatcher |
| `internal/agents/tool/email/` | IMAP-Client + LLM-Email-Analyse |
### Discord Commands
| Slash-Command | @Mention | Funktion |
|---------------|----------|---------|
| `/ask`, `/research` | `@bot <frage>` | Wissensdatenbank abfragen |
| `/asknobrain` | | Direkt an LLM (kein RAG) |
| `/memory store` | `@bot remember <text>` | Text speichern |
| `/memory ingest` | `@bot ingest` | Markdown neu einlesen |
| `/task add/list/done/delete` | `@bot task <aktion>` | Aufgaben verwalten |
| `/email summary/unread/remind` | `@bot email <aktion>` | Email-Analyse |
| `/remember` | | Alias für `/memory store` |
| `/ingest` | | Alias für `/memory ingest` |
## Key Patterns ## Key Patterns
- **Deterministic IDs**: SHA256 of `source:text` — upserting the same content is always idempotent - **Deterministic IDs**: SHA256 of `source:text` — upserting the same content is always idempotent
- **Excluded directories**: `05_Agents` and `.git` are skipped during markdown ingest - **Excluded directories**: `05_Agents` and `.git` are skipped during markdown ingest
- **config.yml** must be present in the working directory; defines Qdrant host/port/api_key, embedding model + dimensions, chat model, `brain_root` path, and `top_k` - **config.yml** must be present in the working directory at runtime
- External services: Qdrant (gRPC port 6334) and LocalAI (HTTP, OpenAI-compatible API) - **Agent Interface**: alle Agenten implementieren `Handle(Request) Response`
- **Defer-first Pattern**: Discord-Handlers senden sofort Defer, dann berechnen — nie >3s warten
- **LLM-Fallback**: Email-Zusammenfassung zeigt Rohliste wenn LLM nicht erreichbar
- **Daemon**: läuft als Goroutine im Discord-Bot-Prozess (`startDaemon()`)
- **config.Cfg**: globale Variable — bei Tests muss `config.LoadConfig()` aufgerufen oder Cfg direkt gesetzt werden
## Model Limitations
Das konfigurierte Modell (`Qwen3.5-4B-Claude-4.6-Opus-Reasoning-Distilled-GGUF`) hat folgende Grenzen:
- **Kontextfenster**: Begrenzt — bei sehr langen Email-Listen oder vielen Chunks kann die Antwort abgeschnitten werden (`MaxTokens: 600`)
- **Latenz**: Lokales Modell auf NAS — Antwortzeiten variieren (560s je nach Last)
- **Encoding**: Betreffzeilen in `windows-1252` (Strato) werden nicht dekodiert — das LLM interpretiert sie trotzdem meist korrekt
- **Halluzinationen**: Das Modell kann bei unklarem Kontext eigenes Wissen einmischen — ist im System-Prompt mit "Aus meinem Wissen:" markiert
- **Streaming-Timeout**: Kein expliziter Timeout auf LLM-Calls — bei Hänger wird Discord-Interaktion erst nach 15min ungültig
## External Services
- **Qdrant** (`192.168.1.4:6334`) — Vektordatenbank, gRPC
- **LocalAI** (`192.168.1.118:8080`) — lokales LLM, OpenAI-kompatibles API
- **Strato IMAP** (`imap.strato.de:143`, STARTTLS) — Email-Abruf
- **Discord** — primäres Interface (Bot-Token in `config.yml`)

View File

@@ -27,4 +27,4 @@ echo "Fertig. Nutzung:"
echo " $OUT_DIR/ingest # Markdown importieren" echo " $OUT_DIR/ingest # Markdown importieren"
echo " $OUT_DIR/ingest bild.json # JSON importieren" echo " $OUT_DIR/ingest bild.json # JSON importieren"
echo " $OUT_DIR/ask \"Was sind meine Pläne?\"" echo " $OUT_DIR/ask \"Was sind meine Pläne?\""
echo " $OUT_DIR/discord-bot # Discord-Bot starten" echo " $OUT_DIR/discord-bot # Discord-Bot + Daemon starten"

38
cmd/agenttest/main.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"fmt"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/agents/task"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/config"
)
func main() {
config.LoadConfig()
fmt.Println("=== Task-Agent Test ===")
a := task.New()
r := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Synology DSM Update durchfuehren"}})
fmt.Println("ADD:", r.Text)
r = a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Zahnarzt Termin bestaetigen"}})
fmt.Println("ADD:", r.Text)
r = a.Handle(agents.Request{Action: agents.ActionList})
fmt.Println("\nLIST:\n" + r.Text)
fmt.Println("\n=== Email-Agent Test mit Testdaten ===")
testMails := []email.Message{
{Subject: "KRITISCH: Synology Update verfuegbar", From: "noreply@synology.com", Date: "2026-03-19 14:09"},
{Subject: "Rechnung Maerz 2026", From: "buchhaltung@strom.de", Date: "2026-03-18 09:00"},
{Subject: "Newsletter GoLang Weekly", From: "newsletter@golangweekly.com", Date: "2026-03-17 18:00"},
}
result, err := email.SummarizeMessages(testMails, "Fasse zusammen und priorisiere nach Dringlichkeit.")
if err != nil {
fmt.Println("LLM-Fehler:", err)
} else {
fmt.Println(result)
}
}

View File

@@ -1,48 +1,59 @@
// discord Discord-Bot für my-brain-importer // discord Discord-Bot für my-brain-importer
// Unterstützt /ask, /ingest und @Mention // Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"log/slog"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/agents/memory"
"my-brain-importer/internal/agents/research"
"my-brain-importer/internal/agents/task"
"my-brain-importer/internal/agents/tool"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/brain" "my-brain-importer/internal/brain"
"my-brain-importer/internal/config" "my-brain-importer/internal/config"
) )
var ( var (
dg *discordgo.Session dg *discordgo.Session
botUser *discordgo.User botUser *discordgo.User
daemonStop = make(chan struct{})
researchAgent agents.Agent
memoryAgent agents.Agent
taskAgent agents.Agent
toolAgent agents.Agent
commands = []*discordgo.ApplicationCommand{ commands = []*discordgo.ApplicationCommand{
{ {
Name: "ask", Name: "ask",
Description: "Stelle eine Frage an deinen AI Brain (mit Wissensdatenbank)", Description: "Stelle eine Frage an deinen AI Brain (mit Wissensdatenbank)",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true},
Type: discordgo.ApplicationCommandOptionString, },
Name: "frage", },
Description: "Die Frage, die du stellen möchtest", {
Required: true, Name: "research",
}, Description: "Wissensdatenbank abfragen (Alias für /ask)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true},
}, },
}, },
{ {
Name: "asknobrain", Name: "asknobrain",
Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)", Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true},
Type: discordgo.ApplicationCommandOptionString,
Name: "frage",
Description: "Die Frage, die du stellen möchtest",
Required: true,
},
}, },
}, },
{ {
@@ -51,13 +62,83 @@ var (
}, },
{ {
Name: "remember", Name: "remember",
Description: "Speichert eine Nachricht in der Wissensdatenbank", Description: "Speichert eine Nachricht in der Wissensdatenbank (Alias für /memory store)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Text", Required: true},
},
},
{
Name: "memory",
Description: "Wissen einspeichern",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionString, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "text", Name: "store",
Description: "Der Text, der gespeichert werden soll", Description: "Text in die Wissensdatenbank speichern",
Required: true, Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Text", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "ingest",
Description: "Markdown-Notizen aus brain_root importieren",
},
},
},
{
Name: "task",
Description: "Aufgabenverwaltung",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add",
Description: "Neuen Task hinzufügen",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Task", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "list",
Description: "Alle Tasks anzeigen",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "done",
Description: "Task als erledigt markieren",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "id", Description: "Task-ID (letzte 6 Zeichen)", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "delete",
Description: "Task löschen",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "id", Description: "Task-ID (letzte 6 Zeichen)", Required: true},
},
},
},
},
{
Name: "email",
Description: "Emails via IMAP abrufen und analysieren",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "summary",
Description: "Zusammenfassung der letzten Emails",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "unread",
Description: "Ungelesene Emails zusammenfassen",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "remind",
Description: "Termine und Deadlines aus Emails extrahieren",
}, },
}, },
}, },
@@ -72,6 +153,20 @@ func main() {
log.Fatal("❌ Kein Discord-Token in config.yml konfiguriert (discord.token)") log.Fatal("❌ Kein Discord-Token in config.yml konfiguriert (discord.token)")
} }
logLevel := slog.LevelInfo
if os.Getenv("DEBUG") == "1" {
logLevel = slog.LevelDebug
fmt.Println("🔍 Debug-Logging aktiv (LLM-Prompts + Antworten werden ausgegeben)")
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: logLevel,
})))
researchAgent = research.New()
memoryAgent = memory.New()
taskAgent = task.New()
toolAgent = tool.New()
var err error var err error
dg, err = discordgo.New("Bot " + token) dg, err = discordgo.New("Bot " + token)
if err != nil { if err != nil {
@@ -92,12 +187,14 @@ func main() {
defer dg.Close() defer dg.Close()
registerCommands() registerCommands()
go startDaemon()
fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.") fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.")
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop <-stop
fmt.Println("\n👋 Bot wird beendet...") fmt.Println("\n👋 Bot wird beendet...")
close(daemonStop)
} }
func onReady(s *discordgo.Session, r *discordgo.Ready) { func onReady(s *discordgo.Session, r *discordgo.Ready) {
@@ -125,23 +222,110 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
switch i.ApplicationCommandData().Name { data := i.ApplicationCommandData()
case "ask": slog.Info("Slash-Command", "command", data.Name, "user", getAuthor(i), "channel", i.ChannelID)
handleAsk(s, i, i.ApplicationCommandData().Options[0].StringValue(), true) switch data.Name {
case "ask", "research":
question := data.Options[0].StringValue()
handleAgentResponse(s, i, func() agents.Response {
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: []string{question}})
})
case "asknobrain": case "asknobrain":
handleAsk(s, i, i.ApplicationCommandData().Options[0].StringValue(), false) handleAskNoBrain(s, i, data.Options[0].StringValue())
case "ingest": case "ingest":
handleIngest(s, i) handleAgentResponse(s, i, func() agents.Response {
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
})
case "remember": case "remember":
handleRemember(s, i) text := data.Options[0].StringValue()
author := getAuthor(i)
source := fmt.Sprintf("discord/#%s", i.ChannelID)
handleAgentResponse(s, i, func() agents.Response {
return memoryAgent.Handle(agents.Request{Action: agents.ActionStore, Args: []string{text}, Author: author, Source: source})
})
case "memory":
handleMemoryCommand(s, i)
case "task":
handleTaskCommand(s, i)
case "email":
handleEmailCommand(s, i)
} }
} }
func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
if m.Author.Bot { sub := i.ApplicationCommandData().Options[0]
return switch sub.Name {
case "store":
text := sub.Options[0].StringValue()
author := getAuthor(i)
source := fmt.Sprintf("discord/#%s", i.ChannelID)
handleAgentResponse(s, i, func() agents.Response {
return memoryAgent.Handle(agents.Request{Action: agents.ActionStore, Args: []string{text}, Author: author, Source: source})
})
case "ingest":
handleAgentResponse(s, i, func() agents.Response {
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
})
} }
if botUser == nil { }
func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
sub := i.ApplicationCommandData().Options[0]
req := agents.Request{Action: sub.Name} // sub.Name ist bereits der Action-String (add/list/done/delete)
if len(sub.Options) > 0 {
req.Args = []string{sub.Options[0].StringValue()}
}
handleAgentResponse(s, i, func() agents.Response {
return taskAgent.Handle(req)
})
}
func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
sub := i.ApplicationCommandData().Options[0]
handleAgentResponse(s, i, func() agents.Response {
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub.Name}})
})
}
func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
answer, err := brain.ChatDirect(question)
var reply string
if err != nil {
reply = fmt.Sprintf("❌ Fehler: %v", err)
} else {
reply = fmt.Sprintf("💬 **Antwort:** _%s_\n\n%s", question, answer)
}
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &reply})
}
// handleAgentResponse sendet erst Defer an Discord, führt dann fn() aus und editiert die Antwort.
// So wird das 3-Sekunden-Timeout von Discord nie überschritten.
func handleAgentResponse(s *discordgo.Session, i *discordgo.InteractionCreate, fn func() agents.Response) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
resp := fn()
if resp.Error != nil {
slog.Error("Agent-Fehler", "fehler", resp.Error)
}
msg := resp.Text
if msg == "" && resp.Error != nil {
msg = fmt.Sprintf("❌ Fehler: %v", resp.Error)
}
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
}
func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.Bot || botUser == nil {
return return
} }
@@ -156,7 +340,6 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
return return
} }
// Mention aus der Nachricht entfernen
question := strings.TrimSpace( question := strings.TrimSpace(
strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""), strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""),
) )
@@ -165,88 +348,162 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
return return
} }
slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question)
s.ChannelTyping(m.ChannelID) s.ChannelTyping(m.ChannelID)
reply := queryAndFormat(question) resp := routeMessage(question, getAuthorFromMessage(m))
s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference()) if resp.Error != nil {
slog.Error("Mention-Fehler", "fehler", resp.Error)
}
s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference())
} }
func handleAsk(s *discordgo.Session, i *discordgo.InteractionCreate, question string, useBrain bool) { // SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung).
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ func SendMessage(channelID, text string) error {
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, if dg == nil {
}) return fmt.Errorf("Discord-Session nicht initialisiert")
}
_, err := dg.ChannelMessageSend(channelID, text)
return err
}
var reply string // routeMessage leitet eine @mention-Nachricht an den passenden Agenten weiter.
if useBrain { // Unterstützte Präfixe: "email ...", "task ...", "remember ...", sonst Research.
reply = queryAndFormat(question) func routeMessage(text, author string) agents.Response {
} else { words := strings.Fields(text)
answer, err := brain.ChatDirect(question) if len(words) == 0 {
if err != nil { return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add <text>`, `task done <id>`, `remember <text>`"}
reply = fmt.Sprintf("❌ Fehler: %v", err) }
} else {
reply = fmt.Sprintf("💬 **Antwort:** _%s_\n\n%s", question, answer) cmd := strings.ToLower(words[0])
args := words[1:]
switch cmd {
case "email":
sub := "summary"
if len(args) > 0 {
sub = strings.ToLower(args[0])
}
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub}})
case "task":
action := "list"
if len(args) > 0 {
action = strings.ToLower(args[0])
args = args[1:]
}
return taskAgent.Handle(agents.Request{Action: action, Args: args})
case "remember":
return memoryAgent.Handle(agents.Request{
Action: agents.ActionStore,
Args: args,
Author: author,
Source: "discord/mention",
})
default:
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: words})
}
}
func getAuthorFromMessage(m *discordgo.MessageCreate) string {
if m.Author != nil {
return m.Author.Username
}
return "unknown"
}
func getAuthor(i *discordgo.InteractionCreate) string {
if i.Member != nil {
return i.Member.User.Username
}
if i.User != nil {
return i.User.Username
}
return "unknown"
}
// startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen.
func startDaemon() {
channelID := config.Cfg.Daemon.ChannelID
if channelID == "" {
log.Println("⚙️ Daemon inaktiv: daemon.channel_id nicht konfiguriert")
return
}
emailInterval := time.Duration(config.Cfg.Daemon.EmailIntervalMin) * time.Minute
if emailInterval == 0 {
emailInterval = 30 * time.Minute
}
reminderHour := config.Cfg.Daemon.TaskReminderHour
if reminderHour == 0 {
reminderHour = 8
}
log.Printf("⚙️ Daemon aktiv: Email-Check alle %v, Task-Reminder täglich um %02d:00", emailInterval, reminderHour)
emailTicker := time.NewTicker(emailInterval)
defer emailTicker.Stop()
taskTimer := scheduleDaily(reminderHour, 0)
defer taskTimer.Stop()
for {
select {
case <-daemonStop:
slog.Info("Daemon gestoppt")
return
case <-emailTicker.C:
slog.Info("Daemon: Email-Check gestartet")
notify, err := email.SummarizeUnread()
if err != nil {
slog.Error("Daemon Email-Fehler", "fehler", err)
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Email-Check fehlgeschlagen: %v", err))
continue
}
if notify != "📭 Keine ungelesenen Emails." {
slog.Info("Daemon: Neue Emails gefunden, sende Zusammenfassung")
dg.ChannelMessageSend(channelID, "📧 **Neue Emails:**\n\n"+notify)
} else {
slog.Info("Daemon: Keine neuen Emails")
}
case <-taskTimer.C:
slog.Info("Daemon: Task-Reminder gestartet")
store := task.NewStore()
open, err := store.OpenTasks()
if err != nil {
slog.Error("Daemon Task-Fehler", "fehler", err)
continue
}
if len(open) > 0 {
var sb strings.Builder
fmt.Fprintf(&sb, "📋 **Tägliche Task-Erinnerung** %d offene Tasks:\n\n", len(open))
for _, t := range open {
shortID := t.ID
if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:]
}
fmt.Fprintf(&sb, "⬜ `%s` %s\n", shortID, t.Text)
}
dg.ChannelMessageSend(channelID, sb.String())
slog.Info("Daemon: Task-Reminder gesendet", "offene_tasks", len(open))
}
taskTimer.Stop()
taskTimer = scheduleDaily(reminderHour, 0)
} }
} }
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &reply,
})
} }
func handleIngest(s *discordgo.Session, i *discordgo.InteractionCreate) { // scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert.
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ func scheduleDaily(hour, minute int) *time.Timer {
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, now := time.Now()
}) next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if !next.After(now) {
fmt.Println("📥 Ingest gestartet via Discord...") next = next.Add(24 * time.Hour)
brain.RunIngest(config.Cfg.BrainRoot) }
d := next.Sub(now)
msg := fmt.Sprintf("✅ Ingest abgeschlossen! Quelle: `%s`", config.Cfg.BrainRoot) log.Printf("⏰ Nächster Task-Reminder in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute)
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ return time.NewTimer(d)
Content: &msg,
})
}
func handleRemember(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
text := i.ApplicationCommandData().Options[0].StringValue()
var author string
if i.Member != nil {
author = i.Member.User.Username
} else if i.User != nil {
author = i.User.Username
} else {
author = "unknown"
}
source := fmt.Sprintf("discord/#%s", i.ChannelID)
err := brain.IngestChatMessage(text, author, source)
var msg string
if err != nil {
msg = fmt.Sprintf("❌ Fehler beim Speichern: %v", err)
} else {
msg = fmt.Sprintf("✅ Gespeichert: _%s_", text)
}
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
}
func queryAndFormat(question string) string {
answer, chunks, err := brain.AskQuery(question)
if err != nil {
return fmt.Sprintf("❌ Fehler: %v", err)
}
if len(chunks) == 0 {
return "❌ 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 sb.String()
} }

110
cmd/mailtest/main.go Normal file
View File

@@ -0,0 +1,110 @@
// mailtest IMAP-Verbindungstest und LLM-Zusammenfassungstest
//
// Flags:
//
// -llm-only Überspringt IMAP, testet LLM mit eingebetteten Testdaten
// -unread Holt nur ungelesene Emails (statt letzte 5)
package main
import (
"flag"
"fmt"
"log"
"log/slog"
"os"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/config"
)
// testEmails sind eingebettete Beispieldaten für den LLM-Test ohne IMAP.
var testEmails = []email.Message{
{Subject: "KRITISCH: Server down in Produktion", From: "monitoring@company.com", Date: "2026-03-19 09:00"},
{Subject: "Meeting morgen 10 Uhr Projektreview Q1", From: "chef@company.com", Date: "2026-03-19 08:30"},
{Subject: "Rechnung Nr. 2026-042 fällig bis 25.03.", From: "buchhaltung@lieferant.de", Date: "2026-03-18 14:00"},
{Subject: "Re: Urlaubsantrag genehmigt", From: "hr@company.com", Date: "2026-03-18 11:00"},
{Subject: "Newsletter: Neue Features in Go 1.26", From: "newsletter@golangweekly.com", Date: "2026-03-17 18:00"},
{Subject: "Amazon: Ihre Bestellung wurde versendet", From: "no-reply@amazon.de", Date: "2026-03-17 10:00"},
{Subject: "Erinnerung: Zahnarzttermin 21.03. um 15:00", From: "praxis@zahnarzt.de", Date: "2026-03-16 09:00"},
}
func main() {
llmOnly := flag.Bool("llm-only", false, "Überspringe IMAP, teste LLM mit eingebetteten Testdaten")
unread := flag.Bool("unread", false, "Hole nur ungelesene Emails")
verbose := flag.Bool("v", false, "Verbose Logging (Debug-Level)")
flag.Parse()
config.LoadConfig()
// Logging konfigurieren
logLevel := slog.LevelInfo
if *verbose {
logLevel = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
if *llmOnly {
runLLMTest()
return
}
cfg := config.Cfg.Email
fmt.Printf("🔌 Verbinde mit %s:%d (TLS=%v StartTLS=%v) als %s ...\n",
cfg.Host, cfg.Port, cfg.TLS, cfg.StartTLS, cfg.User)
cl, err := email.Connect()
if err != nil {
log.Fatalf("❌ Verbindung fehlgeschlagen: %v", err)
}
defer cl.Close()
fmt.Println("✅ Login erfolgreich!")
var msgs []email.Message
if *unread {
fmt.Println("📥 Hole ungelesene Emails...")
msgs, err = cl.FetchUnread()
} else {
fmt.Println("📥 Hole die letzten 5 Emails...")
msgs, err = cl.FetchRecent(5)
}
if err != nil {
log.Fatalf("❌ Fetch fehlgeschlagen: %v", err)
}
if len(msgs) == 0 {
fmt.Println("📭 Keine Emails gefunden.")
return
}
for i, m := range msgs {
fmt.Printf("[%d] %s | Von: %s | %s\n", i+1, m.Date, m.From, m.Subject)
}
fmt.Printf("\n🤖 Teste LLM-Zusammenfassung (Modell: %s)...\n", effectiveModel())
summary, err := email.Summarize()
if err != nil {
log.Fatalf("❌ Fehler: %v", err)
}
fmt.Println(summary)
}
func runLLMTest() {
fmt.Printf("🧪 LLM-Testmodus mit %d eingebetteten Testdaten\n", len(testEmails))
fmt.Printf(" Modell: %s\n\n", effectiveModel())
for i, m := range testEmails {
fmt.Printf("[%d] %s | %s | %s\n", i+1, m.Date, m.From, m.Subject)
}
fmt.Println("\n🤖 Starte LLM-Zusammenfassung...")
result, err := email.SummarizeMessages(testEmails, "Fasse diese Emails zusammen. Priorisiere nach Dringlichkeit und Wichtigkeit.")
if err != nil {
log.Fatalf("❌ LLM-Fehler: %v", err)
}
fmt.Println("\n--- Ergebnis ---")
fmt.Println(result)
}
func effectiveModel() string {
if config.Cfg.Email.Model != "" {
return config.Cfg.Email.Model
}
return config.Cfg.Chat.Model
}

5
go.mod
View File

@@ -3,6 +3,8 @@ module my-brain-importer
go 1.22.2 go 1.22.2
require ( require (
github.com/bwmarrin/discordgo v0.29.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/qdrant/go-client v1.12.0 github.com/qdrant/go-client v1.12.0
github.com/sashabaranov/go-openai v1.37.0 github.com/sashabaranov/go-openai v1.37.0
google.golang.org/grpc v1.71.0 google.golang.org/grpc v1.71.0
@@ -10,7 +12,8 @@ require (
) )
require ( require (
github.com/bwmarrin/discordgo v0.29.0 // indirect github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.32.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect

32
go.sum
View File

@@ -1,5 +1,11 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -16,6 +22,7 @@ github.com/qdrant/go-client v1.12.0 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T
github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw= github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw=
github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY= github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
@@ -28,20 +35,45 @@ go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=

View 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
View 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
}

View 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)}
}

View 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()}
}

View 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 ""
}

View 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
}

View 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}
}

View 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
}

View 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()
}

View File

@@ -5,7 +5,9 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"log/slog"
"strings" "strings"
"time"
pb "github.com/qdrant/go-client/qdrant" pb "github.com/qdrant/go-client/qdrant"
openai "github.com/sashabaranov/go-openai" openai "github.com/sashabaranov/go-openai"
@@ -54,6 +56,13 @@ WICHTIGE REGELN:
Basierend auf diesen Informationen, beantworte bitte folgende Frage: Basierend auf diesen Informationen, beantworte bitte folgende Frage:
%s`, contextText, question) %s`, contextText, question)
slog.Debug("[LLM] AskQuery Prompt",
"model", config.Cfg.Chat.Model,
"system", systemPrompt,
"user", userPrompt,
)
start := time.Now()
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: config.Cfg.Chat.Model, Model: config.Cfg.Chat.Model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
@@ -79,6 +88,12 @@ Basierend auf diesen Informationen, beantworte bitte folgende Frage:
} }
} }
slog.Debug("[LLM] AskQuery Antwort",
"dauer", time.Since(start).Round(time.Millisecond),
"zeichen", answer.Len(),
"antwort", answer.String(),
)
return answer.String(), chunks, nil return answer.String(), chunks, nil
} }
@@ -146,7 +161,7 @@ func searchKnowledge(ctx context.Context, embClient *openai.Client, query string
seen := make(map[string]bool) seen := make(map[string]bool)
for _, hit := range searchResult.Result { for _, hit := range searchResult.Result {
text := hit.Payload["text"].GetStringValue() text := hit.Payload["text"].GetStringValue()
if seen[text] { if text == "" || seen[text] {
continue continue
} }
seen[text] = true seen[text] = true
@@ -176,6 +191,12 @@ func ChatDirect(question string) (string, error) {
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Antworte auf Deutsch, präzise und direkt.` systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Antworte auf Deutsch, präzise und direkt.`
slog.Debug("[LLM] ChatDirect Prompt",
"model", config.Cfg.Chat.Model,
"user", question,
)
start := time.Now()
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: config.Cfg.Chat.Model, Model: config.Cfg.Chat.Model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
@@ -200,6 +221,13 @@ func ChatDirect(question string) (string, error) {
answer.WriteString(response.Choices[0].Delta.Content) answer.WriteString(response.Choices[0].Delta.Content)
} }
} }
slog.Debug("[LLM] ChatDirect Antwort",
"dauer", time.Since(start).Round(time.Millisecond),
"zeichen", answer.Len(),
"antwort", answer.String(),
)
return answer.String(), nil return answer.String(), nil
} }

View File

@@ -36,6 +36,27 @@ type Config struct {
GuildID string `yaml:"guild_id"` GuildID string `yaml:"guild_id"`
} `yaml:"discord"` } `yaml:"discord"`
Email struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
TLS bool `yaml:"tls"`
StartTLS bool `yaml:"starttls"`
Folder string `yaml:"folder"`
Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen
} `yaml:"email"`
Tasks struct {
StorePath string `yaml:"store_path"`
} `yaml:"tasks"`
Daemon struct {
ChannelID string `yaml:"channel_id"`
EmailIntervalMin int `yaml:"email_interval_min"`
TaskReminderHour int `yaml:"task_reminder_hour"`
} `yaml:"daemon"`
BrainRoot string `yaml:"brain_root"` BrainRoot string `yaml:"brain_root"`
TopK uint64 `yaml:"top_k"` TopK uint64 `yaml:"top_k"`
ScoreThreshold float32 `yaml:"score_threshold"` ScoreThreshold float32 `yaml:"score_threshold"`
@@ -70,7 +91,7 @@ func NewChatClient() *openai.Client {
return openai.NewClientWithConfig(c) return openai.NewClientWithConfig(c)
} }
// LoadConfig liest config.yml aus dem aktuellen Verzeichnis. // LoadConfig liest config.yml aus dem aktuellen Verzeichnis und validiert Pflichtfelder.
func LoadConfig() { func LoadConfig() {
data, err := os.ReadFile("config.yml") data, err := os.ReadFile("config.yml")
if err != nil { if err != nil {
@@ -79,4 +100,34 @@ func LoadConfig() {
if err := yaml.Unmarshal(data, &Cfg); err != nil { if err := yaml.Unmarshal(data, &Cfg); err != nil {
log.Fatalf("❌ config.yml ungültig: %v", err) log.Fatalf("❌ config.yml ungültig: %v", err)
} }
validateConfig()
}
// validateConfig prüft Pflichtfelder und gibt früh eine klare Fehlermeldung.
func validateConfig() {
var errs []string
if Cfg.Qdrant.Host == "" {
errs = append(errs, "qdrant.host fehlt")
}
if Cfg.Qdrant.Port == "" {
errs = append(errs, "qdrant.port fehlt")
}
if Cfg.Embedding.URL == "" {
errs = append(errs, "embedding.url fehlt")
}
if Cfg.Embedding.Model == "" {
errs = append(errs, "embedding.model fehlt")
}
if Cfg.Chat.URL == "" {
errs = append(errs, "chat.url fehlt")
}
if Cfg.Chat.Model == "" {
errs = append(errs, "chat.model fehlt")
}
if len(errs) > 0 {
for _, e := range errs {
log.Printf("❌ config.yml: %s", e)
}
log.Fatal("❌ Konfiguration unvollständig Bot wird nicht gestartet.")
}
} }

14
tasks.json Normal file
View File

@@ -0,0 +1,14 @@
[
{
"id": "1773950110942000154",
"text": "Synology DSM Update durchfuehren",
"done": false,
"created_at": "2026-03-19T20:55:10.942001964+01:00"
},
{
"id": "1773950110942351936",
"text": "Zahnarzt Termin bestaetigen",
"done": false,
"created_at": "2026-03-19T20:55:10.942353012+01:00"
}
]