diff --git a/.gitignore b/.gitignore index 7c53b1f..ae6c55c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ config.yml +ask diff --git a/build.sh b/build.sh index 6528e7e..9f36247 100755 --- a/build.sh +++ b/build.sh @@ -16,8 +16,15 @@ GOOS=windows GOARCH=amd64 go build -o "$OUT_DIR/ask.exe" ./cmd/ask/ echo " Linux: $OUT_DIR/ask" echo " Windows: $OUT_DIR/ask.exe" +echo "Baue discord-bot ..." +GOOS=linux GOARCH=amd64 go build -o "$OUT_DIR/discord-bot" ./cmd/discord/ +GOOS=windows GOARCH=amd64 go build -o "$OUT_DIR/discord-bot.exe" ./cmd/discord/ +echo " Linux: $OUT_DIR/discord-bot" +echo " Windows: $OUT_DIR/discord-bot.exe" + echo "" 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" diff --git a/cmd/discord/main.go b/cmd/discord/main.go new file mode 100644 index 0000000..7e66b98 --- /dev/null +++ b/cmd/discord/main.go @@ -0,0 +1,190 @@ +// 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", + 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", + }, + } +) + +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 + for _, cmd := range commands { + _, err := dg.ApplicationCommandCreate(dg.State.User.ID, guildID, cmd) + if err != nil { + log.Printf("⚠️ Slash-Command /%s konnte nicht registriert werden: %v", cmd.Name, err) + } else { + scope := "global" + if guildID != "" { + scope = "guild " + guildID + } + 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()) + case "ingest": + handleIngest(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) { + // Sofort mit "Denke nach..." antworten (Discord-Timeout: 3s) + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + }) + + reply := queryAndFormat(question) + + 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 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() +} diff --git a/go.mod b/go.mod index 8e3f07b..f8cdb7d 100755 --- a/go.mod +++ b/go.mod @@ -10,6 +10,9 @@ require ( ) require ( + github.com/bwmarrin/discordgo v0.29.0 // 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 golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect diff --git a/go.sum b/go.sum index c243372..83f3a92 100755 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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/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= @@ -8,6 +10,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/qdrant/go-client v1.12.0 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T4M= github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw= github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY= @@ -24,12 +28,20 @@ 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-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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= 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/brain/ask.go b/internal/brain/ask.go index 5db0d6b..b033ee2 100755 --- a/internal/brain/ask.go +++ b/internal/brain/ask.go @@ -21,27 +21,21 @@ type KnowledgeChunk struct { Source string } -// Ask sucht relevante Chunks und generiert eine LLM-Antwort per Streaming. -func Ask(question string) { +// AskQuery sucht relevante Chunks und generiert eine LLM-Antwort. +// Gibt die Antwort als String und die verwendeten Quellen zurück. +func AskQuery(question string) (string, []KnowledgeChunk, error) { ctx := context.Background() ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey) - fmt.Printf("🤔 Frage: \"%s\"\n\n", question) - embClient := config.NewEmbeddingClient() chatClient := config.NewChatClient() - fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...") chunks := searchKnowledge(ctx, embClient, question) - if len(chunks) == 0 { - fmt.Println("\n❌ Keine relevanten Informationen in der Datenbank gefunden.") - fmt.Println(" Füge mehr Daten mit './bin/ingest' hinzu.") - return + return "", nil, nil } contextText := buildContext(chunks) - fmt.Printf("✅ %d relevante Informationen gefunden\n\n", len(chunks)) systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Deine Aufgabe ist es, Fragen basierend auf den bereitgestellten Informationen zu beantworten. @@ -60,9 +54,6 @@ WICHTIGE REGELN: Basierend auf diesen Informationen, beantworte bitte folgende Frage: %s`, contextText, question) - fmt.Println("🧠 Generiere Antwort mit lokalem Modell...") - fmt.Println(strings.Repeat("═", 80)) - stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ Model: config.Cfg.Chat.Model, Messages: []openai.ChatCompletionMessage{ @@ -73,21 +64,45 @@ Basierend auf diesen Informationen, beantworte bitte folgende Frage: MaxTokens: 500, }) if err != nil { - log.Fatalf("❌ LLM Fehler: %v", err) + return "", nil, fmt.Errorf("LLM Fehler: %w", err) } defer stream.Close() - fmt.Print("\n💬 Antwort:\n\n") + var answer strings.Builder for { response, err := stream.Recv() if err != nil { break } if len(response.Choices) > 0 { - fmt.Print(response.Choices[0].Delta.Content) + answer.WriteString(response.Choices[0].Delta.Content) } } + return answer.String(), chunks, nil +} + +// Ask sucht relevante Chunks und gibt Antwort + Quellen auf stdout aus. +func Ask(question string) { + fmt.Printf("🤔 Frage: \"%s\"\n\n", question) + fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...") + + answer, chunks, err := AskQuery(question) + if err != nil { + log.Fatalf("❌ %v", err) + } + + if len(chunks) == 0 { + fmt.Println("\n❌ Keine relevanten Informationen in der Datenbank gefunden.") + fmt.Println(" Füge mehr Daten mit './bin/ingest' hinzu.") + return + } + + fmt.Printf("✅ %d relevante Informationen gefunden\n\n", len(chunks)) + fmt.Println("🧠 Generiere Antwort mit lokalem Modell...") + fmt.Println(strings.Repeat("═", 80)) + fmt.Print("\n💬 Antwort:\n\n") + fmt.Print(answer) fmt.Print("\n\n") fmt.Println(strings.Repeat("═", 80)) fmt.Print("\n📚 Verwendete Quellen:\n") diff --git a/internal/config/config.go b/internal/config/config.go index d6413af..253ede5 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,11 @@ type Config struct { Model string `yaml:"model"` } `yaml:"chat"` + Discord struct { + Token string `yaml:"token"` + GuildID string `yaml:"guild_id"` + } `yaml:"discord"` + BrainRoot string `yaml:"brain_root"` TopK uint64 `yaml:"top_k"` ScoreThreshold float32 `yaml:"score_threshold"`