llm mail integration
This commit is contained in:
98
CLAUDE.md
98
CLAUDE.md
@@ -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 (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`)
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -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
38
cmd/agenttest/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
// 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"
|
||||||
)
|
)
|
||||||
@@ -19,30 +27,33 @@ import (
|
|||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <text>`, `task done <id>`, `remember <text>`"}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
})
|
})
|
||||||
|
|
||||||
var reply string
|
default:
|
||||||
if useBrain {
|
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: words})
|
||||||
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{
|
func getAuthorFromMessage(m *discordgo.MessageCreate) string {
|
||||||
Content: &reply,
|
if m.Author != nil {
|
||||||
})
|
return m.Author.Username
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleIngest(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func getAuthor(i *discordgo.InteractionCreate) string {
|
||||||
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 {
|
if i.Member != nil {
|
||||||
author = i.Member.User.Username
|
return i.Member.User.Username
|
||||||
} else if i.User != nil {
|
}
|
||||||
author = i.User.Username
|
if i.User != nil {
|
||||||
} else {
|
return i.User.Username
|
||||||
author = "unknown"
|
}
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
source := fmt.Sprintf("discord/#%s", i.ChannelID)
|
|
||||||
|
|
||||||
err := brain.IngestChatMessage(text, author, source)
|
// startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen.
|
||||||
var msg string
|
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 {
|
if err != nil {
|
||||||
msg = fmt.Sprintf("❌ Fehler beim Speichern: %v", err)
|
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 {
|
} else {
|
||||||
msg = fmt.Sprintf("✅ Gespeichert: _%s_", text)
|
slog.Info("Daemon: Keine neuen Emails")
|
||||||
}
|
|
||||||
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func queryAndFormat(question string) string {
|
case <-taskTimer.C:
|
||||||
answer, chunks, err := brain.AskQuery(question)
|
slog.Info("Daemon: Task-Reminder gestartet")
|
||||||
|
store := task.NewStore()
|
||||||
|
open, err := store.OpenTasks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("❌ Fehler: %v", err)
|
slog.Error("Daemon Task-Fehler", "fehler", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if len(chunks) == 0 {
|
if len(open) > 0 {
|
||||||
return "❌ Keine relevanten Informationen in der Datenbank gefunden.\nFüge mehr Daten mit `/ingest` hinzu."
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "💬 **Antwort auf:** _%s_\n\n", question)
|
fmt.Fprintf(&sb, "📋 **Tägliche Task-Erinnerung** – %d offene Tasks:\n\n", len(open))
|
||||||
sb.WriteString(answer)
|
for _, t := range open {
|
||||||
sb.WriteString("\n\n📚 **Quellen:**\n")
|
shortID := t.ID
|
||||||
for _, chunk := range chunks {
|
if len(shortID) > 6 {
|
||||||
fmt.Fprintf(&sb, "• %.1f%% – %s\n", chunk.Score*100, chunk.Source)
|
shortID = shortID[len(shortID)-6:]
|
||||||
}
|
}
|
||||||
return sb.String()
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
110
cmd/mailtest/main.go
Normal file
110
cmd/mailtest/main.go
Normal 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
5
go.mod
@@ -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
32
go.sum
@@ -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=
|
||||||
|
|||||||
23
internal/agents/actions.go
Normal file
23
internal/agents/actions.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// actions.go – Typsichere Konstanten für Agent-Actions
|
||||||
|
package agents
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Research
|
||||||
|
ActionQuery = "query"
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
ActionStore = "store"
|
||||||
|
ActionIngest = "ingest"
|
||||||
|
|
||||||
|
// Task
|
||||||
|
ActionAdd = "add"
|
||||||
|
ActionList = "list"
|
||||||
|
ActionDone = "done"
|
||||||
|
ActionDelete = "delete"
|
||||||
|
|
||||||
|
// Tool/Email
|
||||||
|
ActionEmail = "email"
|
||||||
|
ActionEmailSummary = "summary"
|
||||||
|
ActionEmailUnread = "unread"
|
||||||
|
ActionEmailRemind = "remind"
|
||||||
|
)
|
||||||
21
internal/agents/agent.go
Normal file
21
internal/agents/agent.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// agent.go – Gemeinsames Interface für alle Agenten
|
||||||
|
package agents
|
||||||
|
|
||||||
|
// Request enthält die Eingabe für einen Agenten.
|
||||||
|
type Request struct {
|
||||||
|
Action string // z.B. "store", "list", "done", "summary"
|
||||||
|
Args []string // Argumente für die Aktion
|
||||||
|
Author string // Discord-Username (für Kontext)
|
||||||
|
Source string // Herkunft (z.B. "discord/#channelID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response enthält die Ausgabe eines Agenten.
|
||||||
|
type Response struct {
|
||||||
|
Text string // Formattierte Antwort
|
||||||
|
Error error // Fehler, falls aufgetreten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent ist das gemeinsame Interface für alle Agenten.
|
||||||
|
type Agent interface {
|
||||||
|
Handle(req Request) Response
|
||||||
|
}
|
||||||
56
internal/agents/memory/agent.go
Normal file
56
internal/agents/memory/agent.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// memory/agent.go – Memory-Agent: wraps brain.RunIngest und brain.IngestChatMessage
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/agents"
|
||||||
|
"my-brain-importer/internal/brain"
|
||||||
|
"my-brain-importer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent verwaltet das Einspeichern von Wissen.
|
||||||
|
type Agent struct{}
|
||||||
|
|
||||||
|
func New() *Agent { return &Agent{} }
|
||||||
|
|
||||||
|
// Handle unterstützt zwei Aktionen:
|
||||||
|
// - "store": Speichert Text als Chat-Nachricht
|
||||||
|
// - "ingest": Startet den Markdown-Ingest aus brain_root
|
||||||
|
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||||
|
switch req.Action {
|
||||||
|
case agents.ActionStore:
|
||||||
|
return a.store(req)
|
||||||
|
case agents.ActionIngest:
|
||||||
|
return a.ingest(req)
|
||||||
|
default:
|
||||||
|
return agents.Response{Text: "❌ Unbekannte Memory-Aktion. Verfügbar: store, ingest"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) store(req agents.Request) agents.Response {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return agents.Response{Text: "❌ Kein Text zum Speichern angegeben."}
|
||||||
|
}
|
||||||
|
text := strings.Join(req.Args, " ")
|
||||||
|
author := req.Author
|
||||||
|
if author == "" {
|
||||||
|
author = "unknown"
|
||||||
|
}
|
||||||
|
source := req.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := brain.IngestChatMessage(text, author, source)
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim Speichern: %v", err)}
|
||||||
|
}
|
||||||
|
return agents.Response{Text: fmt.Sprintf("✅ Gespeichert: _%s_", text)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) ingest(_ agents.Request) agents.Response {
|
||||||
|
brain.RunIngest(config.Cfg.BrainRoot)
|
||||||
|
return agents.Response{Text: fmt.Sprintf("✅ Ingest abgeschlossen! Quelle: `%s`", config.Cfg.BrainRoot)}
|
||||||
|
}
|
||||||
39
internal/agents/research/agent.go
Normal file
39
internal/agents/research/agent.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// research/agent.go – Research-Agent: wraps brain.AskQuery
|
||||||
|
package research
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/agents"
|
||||||
|
"my-brain-importer/internal/brain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent beantwortet Fragen mit der Wissensdatenbank.
|
||||||
|
type Agent struct{}
|
||||||
|
|
||||||
|
func New() *Agent { return &Agent{} }
|
||||||
|
|
||||||
|
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return agents.Response{Text: "❌ Keine Frage angegeben."}
|
||||||
|
}
|
||||||
|
question := strings.Join(req.Args, " ")
|
||||||
|
|
||||||
|
answer, chunks, err := brain.AskQuery(question)
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
if len(chunks) == 0 {
|
||||||
|
return agents.Response{Text: "❌ Keine relevanten Informationen in der Datenbank gefunden.\nFüge mehr Daten mit `/ingest` hinzu."}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "💬 **Antwort auf:** _%s_\n\n", question)
|
||||||
|
sb.WriteString(answer)
|
||||||
|
sb.WriteString("\n\n📚 **Quellen:**\n")
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
fmt.Fprintf(&sb, "• %.1f%% – %s\n", chunk.Score*100, chunk.Source)
|
||||||
|
}
|
||||||
|
return agents.Response{Text: sb.String()}
|
||||||
|
}
|
||||||
133
internal/agents/task/agent.go
Normal file
133
internal/agents/task/agent.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// task/agent.go – Task-Agent: add/list/done/delete
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent verwaltet Aufgaben über tasks.json.
|
||||||
|
type Agent struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// New erstellt einen neuen Task-Agenten.
|
||||||
|
func New() *Agent {
|
||||||
|
return &Agent{store: NewStore()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unterstützt: add, list, done, delete
|
||||||
|
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||||
|
switch req.Action {
|
||||||
|
case agents.ActionAdd:
|
||||||
|
return a.add(req)
|
||||||
|
case agents.ActionList:
|
||||||
|
return a.list()
|
||||||
|
case agents.ActionDone:
|
||||||
|
return a.done(req)
|
||||||
|
case agents.ActionDelete:
|
||||||
|
return a.del(req)
|
||||||
|
default:
|
||||||
|
return agents.Response{Text: "❌ Unbekannte Task-Aktion. Verfügbar: add, list, done, delete"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) add(req agents.Request) agents.Response {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return agents.Response{Text: "❌ Kein Task-Text angegeben."}
|
||||||
|
}
|
||||||
|
text := strings.Join(req.Args, " ")
|
||||||
|
t, err := a.store.Add(text)
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
shortID := t.ID
|
||||||
|
if len(shortID) > 6 {
|
||||||
|
shortID = shortID[len(shortID)-6:]
|
||||||
|
}
|
||||||
|
return agents.Response{Text: fmt.Sprintf("✅ Task hinzugefügt: `%s` (ID: `%s`)", t.Text, shortID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) list() agents.Response {
|
||||||
|
tasks, err := a.store.Load()
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return agents.Response{Text: "📋 Keine Tasks vorhanden."}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📋 **Task-Liste:**\n\n")
|
||||||
|
openCount := 0
|
||||||
|
for _, t := range tasks {
|
||||||
|
status := "⬜"
|
||||||
|
if t.Done {
|
||||||
|
status = "✅"
|
||||||
|
} else {
|
||||||
|
openCount++
|
||||||
|
}
|
||||||
|
shortID := t.ID
|
||||||
|
if len(shortID) > 6 {
|
||||||
|
shortID = shortID[len(shortID)-6:]
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%s `%s` – %s\n", status, shortID, t.Text)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "\n*%d offen, %d gesamt*", openCount, len(tasks))
|
||||||
|
return agents.Response{Text: sb.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) done(req agents.Request) agents.Response {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return agents.Response{Text: "❌ Keine Task-ID angegeben."}
|
||||||
|
}
|
||||||
|
id := req.Args[0]
|
||||||
|
tasks, err := a.store.Load()
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
fullID := resolveID(tasks, id)
|
||||||
|
if fullID == "" {
|
||||||
|
return agents.Response{Text: fmt.Sprintf("❌ Task `%s` nicht gefunden.", id)}
|
||||||
|
}
|
||||||
|
if err := a.store.MarkDone(fullID); err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
return agents.Response{Text: fmt.Sprintf("✅ Task `%s` als erledigt markiert.", id)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) del(req agents.Request) agents.Response {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return agents.Response{Text: "❌ Keine Task-ID angegeben."}
|
||||||
|
}
|
||||||
|
id := req.Args[0]
|
||||||
|
tasks, err := a.store.Load()
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
fullID := resolveID(tasks, id)
|
||||||
|
if fullID == "" {
|
||||||
|
return agents.Response{Text: fmt.Sprintf("❌ Task `%s` nicht gefunden.", id)}
|
||||||
|
}
|
||||||
|
if err := a.store.Delete(fullID); err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
return agents.Response{Text: fmt.Sprintf("🗑️ Task `%s` gelöscht.", id)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveID findet eine vollständige ID aus einer vollständigen oder kurzen (letzte 6 Zeichen).
|
||||||
|
func resolveID(tasks []Task, id string) string {
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.ID == id {
|
||||||
|
return t.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if len(t.ID) >= 6 && t.ID[len(t.ID)-6:] == id {
|
||||||
|
return t.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
172
internal/agents/task/store.go
Normal file
172
internal/agents/task/store.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// task/store.go – JSON-Persistenz für Tasks (atomisches Schreiben)
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task repräsentiert eine Aufgabe.
|
||||||
|
type Task struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DoneAt *time.Time `json:"done_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store verwaltet tasks.json mit atomischen Schreiboperationen.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore erstellt einen Store mit dem Pfad aus der Config.
|
||||||
|
func NewStore() *Store {
|
||||||
|
path := config.Cfg.Tasks.StorePath
|
||||||
|
if path == "" {
|
||||||
|
path = "./tasks.json"
|
||||||
|
}
|
||||||
|
return &Store{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load liest alle Tasks aus der JSON-Datei.
|
||||||
|
func (s *Store) Load() ([]Task, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []Task{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tasks lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks []Task
|
||||||
|
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||||
|
return nil, fmt.Errorf("tasks parsen: %w", err)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save schreibt alle Tasks atomisch. Muss unter mu aufgerufen werden.
|
||||||
|
func (s *Store) save(tasks []Task) error {
|
||||||
|
data, err := json.MarshalIndent(tasks, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tasks serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("temp-datei schreiben: %w", err)
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add fügt einen neuen Task hinzu.
|
||||||
|
func (s *Store) Add(text string) (Task, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tasks, err := s.loadLocked()
|
||||||
|
if err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := Task{
|
||||||
|
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
|
Text: text,
|
||||||
|
Done: false,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
tasks = append(tasks, t)
|
||||||
|
|
||||||
|
if err := s.save(tasks); err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDone markiert einen Task als erledigt.
|
||||||
|
func (s *Store) MarkDone(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tasks, err := s.loadLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
now := time.Now()
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.ID == id {
|
||||||
|
tasks[i].Done = true
|
||||||
|
tasks[i].DoneAt = &now
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("task %q nicht gefunden", id)
|
||||||
|
}
|
||||||
|
return s.save(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete löscht einen Task.
|
||||||
|
func (s *Store) Delete(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tasks, err := s.loadLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newTasks := tasks[:0]
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.ID != id {
|
||||||
|
newTasks = append(newTasks, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newTasks) == len(tasks) {
|
||||||
|
return fmt.Errorf("task %q nicht gefunden", id)
|
||||||
|
}
|
||||||
|
return s.save(newTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTasks gibt alle offenen Tasks zurück.
|
||||||
|
func (s *Store) OpenTasks() ([]Task, error) {
|
||||||
|
tasks, err := s.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var open []Task
|
||||||
|
for _, t := range tasks {
|
||||||
|
if !t.Done {
|
||||||
|
open = append(open, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return open, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLocked liest ohne eigenes Lock (muss unter s.mu aufgerufen werden).
|
||||||
|
func (s *Store) loadLocked() ([]Task, error) {
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []Task{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tasks lesen: %w", err)
|
||||||
|
}
|
||||||
|
var tasks []Task
|
||||||
|
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||||
|
return nil, fmt.Errorf("tasks parsen: %w", err)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
52
internal/agents/tool/agent.go
Normal file
52
internal/agents/tool/agent.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// tool/agent.go – Tool-Agent: Dispatcher für externe Tools (Email, ...)
|
||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/agents"
|
||||||
|
"my-brain-importer/internal/agents/tool/email"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent verteilt Tool-Anfragen an spezialisierte Sub-Agenten.
|
||||||
|
type Agent struct{}
|
||||||
|
|
||||||
|
func New() *Agent { return &Agent{} }
|
||||||
|
|
||||||
|
// Handle unterstützt: email
|
||||||
|
func (a *Agent) Handle(req agents.Request) agents.Response {
|
||||||
|
switch req.Action {
|
||||||
|
case agents.ActionEmail:
|
||||||
|
return a.handleEmail(req)
|
||||||
|
default:
|
||||||
|
return agents.Response{Text: "❌ Unbekannte Tool-Aktion. Verfügbar: email"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handleEmail(req agents.Request) agents.Response {
|
||||||
|
subAction := agents.ActionEmailSummary
|
||||||
|
if len(req.Args) > 0 {
|
||||||
|
subAction = req.Args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
result string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch subAction {
|
||||||
|
case agents.ActionEmailSummary:
|
||||||
|
result, err = email.Summarize()
|
||||||
|
case agents.ActionEmailUnread:
|
||||||
|
result, err = email.SummarizeUnread()
|
||||||
|
case agents.ActionEmailRemind:
|
||||||
|
result, err = email.ExtractReminders()
|
||||||
|
default:
|
||||||
|
return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind", subAction)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Email-Fehler: %v", err)}
|
||||||
|
}
|
||||||
|
return agents.Response{Text: "📧 **Email-Analyse:**\n\n" + result}
|
||||||
|
}
|
||||||
150
internal/agents/tool/email/client.go
Normal file
150
internal/agents/tool/email/client.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// email/client.go – IMAP-Client für Email-Abfragen
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
imap "github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message repräsentiert eine Email (ohne Body für schnelle Übersichten).
|
||||||
|
type Message struct {
|
||||||
|
Subject string
|
||||||
|
From string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client wraps die IMAP-Verbindung.
|
||||||
|
type Client struct {
|
||||||
|
c *imapclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect öffnet eine IMAP-Verbindung.
|
||||||
|
func Connect() (*Client, error) {
|
||||||
|
cfg := config.Cfg.Email
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
|
||||||
|
var (
|
||||||
|
c *imapclient.Client
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case cfg.TLS:
|
||||||
|
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||||
|
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||||
|
case cfg.StartTLS:
|
||||||
|
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||||
|
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||||
|
default:
|
||||||
|
c, err = imapclient.DialInsecure(addr, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP verbinden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
|
||||||
|
c.Close()
|
||||||
|
return nil, fmt.Errorf("IMAP login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{c: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close schließt die Verbindung.
|
||||||
|
func (cl *Client) Close() {
|
||||||
|
cl.c.Logout().Wait()
|
||||||
|
cl.c.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body).
|
||||||
|
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
||||||
|
folder := config.Cfg.Email.Folder
|
||||||
|
if folder == "" {
|
||||||
|
folder = "INBOX"
|
||||||
|
}
|
||||||
|
|
||||||
|
selectData, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP select: %w", err)
|
||||||
|
}
|
||||||
|
if selectData.NumMessages == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start := uint32(1)
|
||||||
|
if selectData.NumMessages > n {
|
||||||
|
start = selectData.NumMessages - n + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var seqSet imap.SeqSet
|
||||||
|
seqSet.AddRange(start, selectData.NumMessages)
|
||||||
|
|
||||||
|
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseMessages(msgs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
|
||||||
|
func (cl *Client) FetchUnread() ([]Message, error) {
|
||||||
|
folder := config.Cfg.Email.Folder
|
||||||
|
if folder == "" {
|
||||||
|
folder = "INBOX"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP select: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchData, err := cl.c.Search(&imap.SearchCriteria{
|
||||||
|
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||||
|
}, nil).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seqNums := searchData.AllSeqNums()
|
||||||
|
if len(seqNums) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var seqSet imap.SeqSet
|
||||||
|
seqSet.AddNum(seqNums...)
|
||||||
|
|
||||||
|
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseMessages(msgs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
|
||||||
|
result := make([]Message, 0, len(msgs))
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg.Envelope == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := Message{
|
||||||
|
Subject: msg.Envelope.Subject,
|
||||||
|
Date: msg.Envelope.Date.Format("2006-01-02 15:04"),
|
||||||
|
}
|
||||||
|
if len(msg.Envelope.From) > 0 {
|
||||||
|
addr := msg.Envelope.From[0]
|
||||||
|
if addr.Name != "" {
|
||||||
|
m.From = fmt.Sprintf("%s <%s@%s>", addr.Name, addr.Mailbox, addr.Host)
|
||||||
|
} else {
|
||||||
|
m.From = fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, m)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
156
internal/agents/tool/email/summary.go
Normal file
156
internal/agents/tool/email/summary.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// email/summary.go – LLM-Zusammenfassung von Emails via LocalAI
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
openai "github.com/sashabaranov/go-openai"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen.
|
||||||
|
func Summarize() (string, error) {
|
||||||
|
return fetchAndSummarize(20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeUnread fasst ungelesene Emails zusammen.
|
||||||
|
func SummarizeUnread() (string, error) {
|
||||||
|
cl, err := Connect()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
|
||||||
|
msgs, err := cl.FetchUnread()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Emails abrufen: %w", err)
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return "📭 Keine ungelesenen Emails.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread")
|
||||||
|
return summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines.
|
||||||
|
func ExtractReminders() (string, error) {
|
||||||
|
return fetchAndSummarize(30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeMessages fasst eine übergebene Liste von Nachrichten zusammen (für Tests ohne IMAP).
|
||||||
|
func SummarizeMessages(msgs []Message, instruction string) (string, error) {
|
||||||
|
return summarizeWithLLM(msgs, instruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAndSummarize(n uint32, instruction string) (string, error) {
|
||||||
|
cl, err := Connect()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
|
||||||
|
msgs, err := cl.FetchRecent(n)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Emails abrufen: %w", err)
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return "📭 Keine Emails gefunden.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs))
|
||||||
|
return summarizeWithLLM(msgs, instruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück.
|
||||||
|
// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist.
|
||||||
|
func emailModel() string {
|
||||||
|
if config.Cfg.Email.Model != "" {
|
||||||
|
return config.Cfg.Email.Model
|
||||||
|
}
|
||||||
|
return config.Cfg.Chat.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatEmailList formatiert Emails als lesbaren Text (Fallback und Eingabe fürs LLM).
|
||||||
|
func formatEmailList(msgs []Message) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, m := range msgs {
|
||||||
|
fmt.Fprintf(&sb, "[%d] Von: %s | Datum: %s | Betreff: %s\n", i+1, m.From, m.Date, m.Subject)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeWithLLM(msgs []Message, instruction string) (string, error) {
|
||||||
|
emailList := formatEmailList(msgs)
|
||||||
|
model := emailModel()
|
||||||
|
|
||||||
|
chatClient := config.NewChatClient()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Analysiere Email-Listen und antworte auf Deutsch, präzise und strukturiert.`
|
||||||
|
userPrompt := fmt.Sprintf("%s\n\nEmail-Liste:\n%s", instruction, emailList)
|
||||||
|
|
||||||
|
slog.Debug("[LLM] Email Prompt",
|
||||||
|
"model", model,
|
||||||
|
"emails", len(msgs),
|
||||||
|
"system", systemPrompt,
|
||||||
|
"user", userPrompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []openai.ChatCompletionMessage{
|
||||||
|
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||||||
|
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
|
||||||
|
},
|
||||||
|
Temperature: 0.5,
|
||||||
|
MaxTokens: 600,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("[LLM] nicht erreichbar, Fallback-Liste", "fehler", err)
|
||||||
|
return fallbackList(msgs), nil
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
var answer strings.Builder
|
||||||
|
for {
|
||||||
|
resp, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(resp.Choices) > 0 {
|
||||||
|
answer.WriteString(resp.Choices[0].Delta.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := answer.String()
|
||||||
|
slog.Debug("[LLM] Email Antwort",
|
||||||
|
"dauer", time.Since(start).Round(time.Millisecond),
|
||||||
|
"zeichen", len(result),
|
||||||
|
"antwort", result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if strings.TrimSpace(result) == "" {
|
||||||
|
slog.Warn("[LLM] leere Antwort, Fallback-Liste")
|
||||||
|
return fallbackList(msgs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[LLM] Email-Zusammenfassung abgeschlossen", "dauer", time.Since(start).Round(time.Millisecond), "zeichen", len(result))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackList gibt eine einfache formatierte Liste zurück wenn das LLM nicht verfügbar ist.
|
||||||
|
func fallbackList(msgs []Message) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("⚠️ *LLM nicht verfügbar – ungefilterte Email-Liste:*\n\n")
|
||||||
|
for i, m := range msgs {
|
||||||
|
fmt.Fprintf(&sb, "**[%d]** %s\n📤 %s\n📌 %s\n\n", i+1, m.Date, m.From, m.Subject)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
14
tasks.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user