From 0e7aa3e7f2cfb827cfd97e002629876e5dd334ae Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Thu, 19 Mar 2026 21:46:12 +0100 Subject: [PATCH] llm mail integration --- CLAUDE.md | 166 ++++-- build.sh | 2 +- cmd/agenttest/main.go | 38 ++ cmd/discord/main.go | 761 +++++++++++++++++--------- cmd/mailtest/main.go | 110 ++++ go.mod | 5 +- go.sum | 32 ++ internal/agents/actions.go | 23 + internal/agents/agent.go | 21 + internal/agents/memory/agent.go | 56 ++ internal/agents/research/agent.go | 39 ++ internal/agents/task/agent.go | 133 +++++ internal/agents/task/store.go | 172 ++++++ internal/agents/tool/agent.go | 52 ++ internal/agents/tool/email/client.go | 150 +++++ internal/agents/tool/email/summary.go | 156 ++++++ internal/brain/ask.go | 30 +- internal/config/config.go | 53 +- tasks.json | 14 + 19 files changed, 1707 insertions(+), 306 deletions(-) create mode 100644 cmd/agenttest/main.go create mode 100644 cmd/mailtest/main.go create mode 100644 internal/agents/actions.go create mode 100644 internal/agents/agent.go create mode 100644 internal/agents/memory/agent.go create mode 100644 internal/agents/research/agent.go create mode 100644 internal/agents/task/agent.go create mode 100644 internal/agents/task/store.go create mode 100644 internal/agents/tool/agent.go create mode 100644 internal/agents/tool/email/client.go create mode 100644 internal/agents/tool/email/summary.go create mode 100644 tasks.json diff --git a/CLAUDE.md b/CLAUDE.md index 263cdb9..fb583db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,50 +1,116 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## 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. - -## Commands - -```bash -# Build all binaries (Linux + Windows cross-compile) -bash build.sh - -# Run directly without building -go run ./cmd/ingest/ -go run ./cmd/ask/ "your question here" - -# Build individual binaries -go build ./cmd/ingest/ -go build ./cmd/ask/ - -# Run tests -go test ./... - -# Tidy dependencies -go mod tidy -``` - -Binaries are output to `./bin/`. The `config.yml` file must exist in the working directory at runtime. - -## Architecture - -Two CLI tools share a common internal library: - -**`cmd/ingest/`** → `internal/brain/ingest.go` + `internal/brain/ingest_json.go` -- 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 - -**`cmd/ask/`** → `internal/brain/ask.go` -- 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). - -## Key Patterns - -- **Deterministic IDs**: SHA256 of `source:text` — upserting the same content is always idempotent -- **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` -- External services: Qdrant (gRPC port 6334) and LocalAI (HTTP, OpenAI-compatible API) +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**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 + +```bash +# Build all binaries (Linux + Windows cross-compile) +bash build.sh + +# Primary entry point: Discord Bot (includes daemon) +go run ./cmd/discord/ + +# CLI tools +go run ./cmd/ingest/ # Markdown importieren +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 +go test ./... + +# Tidy dependencies +go mod tidy +``` + +Binaries are output to `./bin/`. The `config.yml` file must exist in the working directory at runtime. + +## Architecture + +``` +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/ + cmd/ask/ (CLI-Tools, direkt nutzbar) + ↓ +internal/brain/ (Core RAG-Logik, unverändert) + ↓ +Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) +``` + +### Packages + +| 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 ` | Wissensdatenbank abfragen | +| `/asknobrain` | – | Direkt an LLM (kein RAG) | +| `/memory store` | `@bot remember ` | Text speichern | +| `/memory ingest` | `@bot ingest` | Markdown neu einlesen | +| `/task add/list/done/delete` | `@bot task ` | Aufgaben verwalten | +| `/email summary/unread/remind` | `@bot email ` | Email-Analyse | +| `/remember` | – | Alias für `/memory store` | +| `/ingest` | – | Alias für `/memory ingest` | + +## Key Patterns + +- **Deterministic IDs**: SHA256 of `source:text` — upserting the same content is always idempotent +- **Excluded directories**: `05_Agents` and `.git` are skipped during markdown ingest +- **config.yml** must be present in the working directory at runtime +- **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 (5–60s 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`) diff --git a/build.sh b/build.sh index 9f36247..8e672e1 100755 --- a/build.sh +++ b/build.sh @@ -27,4 +27,4 @@ echo "Fertig. Nutzung:" echo " $OUT_DIR/ingest # Markdown importieren" echo " $OUT_DIR/ingest bild.json # JSON importieren" 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" diff --git a/cmd/agenttest/main.go b/cmd/agenttest/main.go new file mode 100644 index 0000000..d60d1bd --- /dev/null +++ b/cmd/agenttest/main.go @@ -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) + } +} diff --git a/cmd/discord/main.go b/cmd/discord/main.go index 7a84387..e85ce5d 100644 --- a/cmd/discord/main.go +++ b/cmd/discord/main.go @@ -1,252 +1,509 @@ -// discord – Discord-Bot für my-brain-importer -// Unterstützt /ask, /ingest und @Mention -package main - -import ( - "fmt" - "log" - "os" - "os/signal" - "strings" - "syscall" - - "github.com/bwmarrin/discordgo" - - "my-brain-importer/internal/brain" - "my-brain-importer/internal/config" -) - -var ( - dg *discordgo.Session - botUser *discordgo.User - - commands = []*discordgo.ApplicationCommand{ - { - Name: "ask", - Description: "Stelle eine Frage an deinen AI Brain (mit Wissensdatenbank)", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "frage", - Description: "Die Frage, die du stellen möchtest", - Required: true, - }, - }, - }, - { - Name: "asknobrain", - Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "frage", - Description: "Die Frage, die du stellen möchtest", - Required: true, - }, - }, - }, - { - Name: "ingest", - Description: "Importiert Markdown-Notizen aus brain_root in die Wissensdatenbank", - }, - { - Name: "remember", - Description: "Speichert eine Nachricht in der Wissensdatenbank", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "text", - Description: "Der Text, der gespeichert werden soll", - Required: true, - }, - }, - }, - } -) - -func main() { - config.LoadConfig() - - token := config.Cfg.Discord.Token - if token == "" || token == "dein-discord-bot-token" { - log.Fatal("❌ Kein Discord-Token in config.yml konfiguriert (discord.token)") - } - - var err error - dg, err = discordgo.New("Bot " + token) - if err != nil { - log.Fatalf("❌ Discord-Session konnte nicht erstellt werden: %v", err) - } - - dg.AddHandler(onReady) - dg.AddHandler(onInteraction) - dg.AddHandler(onMessage) - - dg.Identify.Intents = discordgo.IntentsGuilds | - discordgo.IntentsGuildMessages | - discordgo.IntentMessageContent - - if err = dg.Open(); err != nil { - log.Fatalf("❌ Verbindung zu Discord fehlgeschlagen: %v", err) - } - defer dg.Close() - - registerCommands() - - fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.") - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - fmt.Println("\n👋 Bot wird beendet...") -} - -func onReady(s *discordgo.Session, r *discordgo.Ready) { - botUser = r.User - fmt.Printf("✅ Eingeloggt als %s#%s\n", r.User.Username, r.User.Discriminator) -} - -func registerCommands() { - guildID := config.Cfg.Discord.GuildID - registered, err := dg.ApplicationCommandBulkOverwrite(dg.State.User.ID, guildID, commands) - if err != nil { - log.Fatalf("❌ Slash-Commands konnten nicht registriert werden: %v", err) - } - scope := "global" - if guildID != "" { - scope = "guild " + guildID - } - for _, cmd := range registered { - fmt.Printf("📝 Slash-Command /%s registriert (%s)\n", cmd.Name, scope) - } -} - -func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { - if i.Type != discordgo.InteractionApplicationCommand { - return - } - - switch i.ApplicationCommandData().Name { - case "ask": - handleAsk(s, i, i.ApplicationCommandData().Options[0].StringValue(), true) - case "asknobrain": - handleAsk(s, i, i.ApplicationCommandData().Options[0].StringValue(), false) - case "ingest": - handleIngest(s, i) - case "remember": - handleRemember(s, i) - } -} - -func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { - if m.Author.Bot { - return - } - if botUser == nil { - return - } - - mentioned := false - for _, u := range m.Mentions { - if u.ID == botUser.ID { - mentioned = true - break - } - } - if !mentioned { - return - } - - // Mention aus der Nachricht entfernen - question := strings.TrimSpace( - strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""), - ) - if question == "" { - s.ChannelMessageSend(m.ChannelID, "Stell mir eine Frage! Beispiel: @Brain Was sind meine TODOs?") - return - } - - s.ChannelTyping(m.ChannelID) - reply := queryAndFormat(question) - s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference()) -} - -func handleAsk(s *discordgo.Session, i *discordgo.InteractionCreate, question string, useBrain bool) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, - }) - - var reply string - if useBrain { - reply = queryAndFormat(question) - } else { - answer, err := brain.ChatDirect(question) - 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, - }) -} - -func handleIngest(s *discordgo.Session, i *discordgo.InteractionCreate) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, - }) - - fmt.Println("📥 Ingest gestartet via Discord...") - brain.RunIngest(config.Cfg.BrainRoot) - - msg := fmt.Sprintf("✅ Ingest abgeschlossen! Quelle: `%s`", config.Cfg.BrainRoot) - s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - 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() -} +// discord – Discord-Bot für my-brain-importer +// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention +package main + +import ( + "fmt" + "log" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "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/config" +) + +var ( + dg *discordgo.Session + botUser *discordgo.User + daemonStop = make(chan struct{}) + + researchAgent agents.Agent + memoryAgent agents.Agent + taskAgent agents.Agent + toolAgent agents.Agent + + commands = []*discordgo.ApplicationCommand{ + { + Name: "ask", + Description: "Stelle eine Frage an deinen AI Brain (mit Wissensdatenbank)", + Options: []*discordgo.ApplicationCommandOption{ + {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", 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", + Description: "Stelle eine Frage direkt ans LLM (ohne Wissensdatenbank)", + Options: []*discordgo.ApplicationCommandOption{ + {Type: discordgo.ApplicationCommandOptionString, Name: "frage", Description: "Die Frage", Required: true}, + }, + }, + { + Name: "ingest", + Description: "Importiert Markdown-Notizen aus brain_root in die Wissensdatenbank", + }, + { + Name: "remember", + 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{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "store", + Description: "Text in die Wissensdatenbank speichern", + 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", + }, + }, + }, + } +) + +func main() { + config.LoadConfig() + + token := config.Cfg.Discord.Token + if token == "" || token == "dein-discord-bot-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 + dg, err = discordgo.New("Bot " + token) + if err != nil { + log.Fatalf("❌ Discord-Session konnte nicht erstellt werden: %v", err) + } + + dg.AddHandler(onReady) + dg.AddHandler(onInteraction) + dg.AddHandler(onMessage) + + dg.Identify.Intents = discordgo.IntentsGuilds | + discordgo.IntentsGuildMessages | + discordgo.IntentMessageContent + + if err = dg.Open(); err != nil { + log.Fatalf("❌ Verbindung zu Discord fehlgeschlagen: %v", err) + } + defer dg.Close() + + registerCommands() + go startDaemon() + + fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.") + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + fmt.Println("\n👋 Bot wird beendet...") + close(daemonStop) +} + +func onReady(s *discordgo.Session, r *discordgo.Ready) { + botUser = r.User + fmt.Printf("✅ Eingeloggt als %s#%s\n", r.User.Username, r.User.Discriminator) +} + +func registerCommands() { + guildID := config.Cfg.Discord.GuildID + registered, err := dg.ApplicationCommandBulkOverwrite(dg.State.User.ID, guildID, commands) + if err != nil { + log.Fatalf("❌ Slash-Commands konnten nicht registriert werden: %v", err) + } + scope := "global" + if guildID != "" { + scope = "guild " + guildID + } + for _, cmd := range registered { + fmt.Printf("📝 Slash-Command /%s registriert (%s)\n", cmd.Name, scope) + } +} + +func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type != discordgo.InteractionApplicationCommand { + return + } + + data := i.ApplicationCommandData() + slog.Info("Slash-Command", "command", data.Name, "user", getAuthor(i), "channel", i.ChannelID) + 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": + handleAskNoBrain(s, i, data.Options[0].StringValue()) + + case "ingest": + handleAgentResponse(s, i, func() agents.Response { + return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest}) + }) + + case "remember": + 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 handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + sub := i.ApplicationCommandData().Options[0] + 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}) + }) + } +} + +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 + } + + mentioned := false + for _, u := range m.Mentions { + if u.ID == botUser.ID { + mentioned = true + break + } + } + if !mentioned { + return + } + + question := strings.TrimSpace( + strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""), + ) + if question == "" { + s.ChannelMessageSend(m.ChannelID, "Stell mir eine Frage! Beispiel: @Brain Was sind meine TODOs?") + return + } + + slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question) + s.ChannelTyping(m.ChannelID) + resp := routeMessage(question, getAuthorFromMessage(m)) + if resp.Error != nil { + slog.Error("Mention-Fehler", "fehler", resp.Error) + } + s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference()) +} + +// SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung). +func SendMessage(channelID, text string) error { + if dg == nil { + return fmt.Errorf("Discord-Session nicht initialisiert") + } + _, err := dg.ChannelMessageSend(channelID, text) + return err +} + +// routeMessage leitet eine @mention-Nachricht an den passenden Agenten weiter. +// Unterstützte Präfixe: "email ...", "task ...", "remember ...", sonst Research. +func routeMessage(text, author string) agents.Response { + words := strings.Fields(text) + if len(words) == 0 { + return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add `, `task done `, `remember `"} + } + + 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) + } + } +} + +// scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert. +func scheduleDaily(hour, minute int) *time.Timer { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + d := next.Sub(now) + log.Printf("⏰ Nächster Task-Reminder in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute) + return time.NewTimer(d) +} diff --git a/cmd/mailtest/main.go b/cmd/mailtest/main.go new file mode 100644 index 0000000..13293f5 --- /dev/null +++ b/cmd/mailtest/main.go @@ -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 +} diff --git a/go.mod b/go.mod index f8cdb7d..2ce9b3e 100755 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module my-brain-importer go 1.22.2 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/sashabaranov/go-openai v1.37.0 google.golang.org/grpc v1.71.0 @@ -10,7 +12,8 @@ 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 golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect diff --git a/go.sum b/go.sum index 83f3a92..af3c555 100755 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ 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/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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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/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/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/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 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-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/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-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/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-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.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/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-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/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= diff --git a/internal/agents/actions.go b/internal/agents/actions.go new file mode 100644 index 0000000..3d4ba1a --- /dev/null +++ b/internal/agents/actions.go @@ -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" +) diff --git a/internal/agents/agent.go b/internal/agents/agent.go new file mode 100644 index 0000000..5286eeb --- /dev/null +++ b/internal/agents/agent.go @@ -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 +} diff --git a/internal/agents/memory/agent.go b/internal/agents/memory/agent.go new file mode 100644 index 0000000..ec6a716 --- /dev/null +++ b/internal/agents/memory/agent.go @@ -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)} +} diff --git a/internal/agents/research/agent.go b/internal/agents/research/agent.go new file mode 100644 index 0000000..0bcb806 --- /dev/null +++ b/internal/agents/research/agent.go @@ -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()} +} diff --git a/internal/agents/task/agent.go b/internal/agents/task/agent.go new file mode 100644 index 0000000..7ea4811 --- /dev/null +++ b/internal/agents/task/agent.go @@ -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 "" +} diff --git a/internal/agents/task/store.go b/internal/agents/task/store.go new file mode 100644 index 0000000..8469d1e --- /dev/null +++ b/internal/agents/task/store.go @@ -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 +} diff --git a/internal/agents/tool/agent.go b/internal/agents/tool/agent.go new file mode 100644 index 0000000..4465ce4 --- /dev/null +++ b/internal/agents/tool/agent.go @@ -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} +} diff --git a/internal/agents/tool/email/client.go b/internal/agents/tool/email/client.go new file mode 100644 index 0000000..0fe6164 --- /dev/null +++ b/internal/agents/tool/email/client.go @@ -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 +} diff --git a/internal/agents/tool/email/summary.go b/internal/agents/tool/email/summary.go new file mode 100644 index 0000000..4e3c6bb --- /dev/null +++ b/internal/agents/tool/email/summary.go @@ -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() +} diff --git a/internal/brain/ask.go b/internal/brain/ask.go index 019c59e..a5e5249 100755 --- a/internal/brain/ask.go +++ b/internal/brain/ask.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "log" + "log/slog" "strings" + "time" pb "github.com/qdrant/go-client/qdrant" openai "github.com/sashabaranov/go-openai" @@ -54,6 +56,13 @@ WICHTIGE REGELN: Basierend auf diesen Informationen, beantworte bitte folgende Frage: %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{ Model: config.Cfg.Chat.Model, 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 } @@ -146,7 +161,7 @@ func searchKnowledge(ctx context.Context, embClient *openai.Client, query string seen := make(map[string]bool) for _, hit := range searchResult.Result { text := hit.Payload["text"].GetStringValue() - if seen[text] { + if text == "" || seen[text] { continue } 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.` + slog.Debug("[LLM] ChatDirect Prompt", + "model", config.Cfg.Chat.Model, + "user", question, + ) + + start := time.Now() stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ Model: config.Cfg.Chat.Model, Messages: []openai.ChatCompletionMessage{ @@ -200,6 +221,13 @@ func ChatDirect(question string) (string, error) { 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 } diff --git a/internal/config/config.go b/internal/config/config.go index 253ede5..e12042e 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,27 @@ type Config struct { GuildID string `yaml:"guild_id"` } `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"` TopK uint64 `yaml:"top_k"` ScoreThreshold float32 `yaml:"score_threshold"` @@ -70,7 +91,7 @@ func NewChatClient() *openai.Client { 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() { data, err := os.ReadFile("config.yml") if err != nil { @@ -79,4 +100,34 @@ func LoadConfig() { if err := yaml.Unmarshal(data, &Cfg); err != nil { 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.") + } } diff --git a/tasks.json b/tasks.json new file mode 100644 index 0000000..cf94e5d --- /dev/null +++ b/tasks.json @@ -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" + } +] \ No newline at end of file