This commit is contained in:
Christoph K.
2026-03-20 07:08:00 +01:00
parent 8163f906cc
commit b1a576f61e
9 changed files with 851 additions and 17 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**my-brain-importer** is a personal AI assistant and RAG (Retrieval-Augmented Generation) system written in Go. It ingests Markdown notes into a Qdrant vector database, answers questions using a local LLM (LocalAI), and is primarily controlled via Discord. A background daemon sends proactive email summaries and task reminders.
**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 a daily morning briefing.
## Commands
@@ -31,6 +31,10 @@ go test ./...
# Tidy dependencies
go mod tidy
# Deployment auf Remote-Server (192.168.1.118)
cp deploy.env.example deploy.env # einmalig: Credentials eintragen
bash deploy.sh # build + upload + systemctl restart
```
Binaries are output to `./bin/`. The `config.yml` file must exist in the working directory at runtime.
@@ -41,18 +45,18 @@ Binaries are output to `./bin/`. The `config.yml` file must exist in the working
Discord (primäres Interface)
↓ Slash-Commands + @Mention
cmd/discord/main.go
├── internal/agents/research/ → brain.AskQuery()
├── internal/agents/research/ → brain.AskQuery() + Konversationsverlauf
├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage()
├── internal/agents/task/ → tasks.json (atomisches JSON)
└── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung
├── internal/agents/task/ → tasks.json (atomisches JSON, DueDate + Priority)
└── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung + Move to Processed
[Daemon-Goroutine] startDaemon()
├── Email-Check (alle N min) → #localagent Discord-Channel
└── Task-Reminder (täglich) → #localagent Discord-Channel
├── Email-Check (alle N min) → #localagent Discord-Channel
└── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert
cmd/ingest/ + cmd/ask/ (CLI-Tools, direkt nutzbar)
internal/brain/ (Core RAG-Logik, unverändert)
internal/brain/ (Core RAG-Logik)
Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
```
@@ -67,22 +71,23 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
| `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/` | Agent-Interface (`Request`/`Response`/`HistoryMessage`) |
| `internal/agents/research/` | Research-Agent: Wissensdatenbank-Abfragen (mit History) |
| `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 |
| `internal/agents/tool/email/` | IMAP-Client + LLM-Email-Analyse + Move to Processed |
### Discord Commands
| Slash-Command | @Mention | Funktion |
|---------------|----------|---------|
| `/ask`, `/research` | `@bot <frage>` | Wissensdatenbank abfragen |
| `/ask`, `/research` | `@bot <frage>` | Wissensdatenbank abfragen (mit Chat-Gedächtnis) |
| `/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 |
| `/task add <text> [faellig] [prioritaet]` | `@bot task add <text> [--due YYYY-MM-DD] [--priority hoch]` | Task hinzufügen |
| `/task 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` |
@@ -97,6 +102,37 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
- **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
- **Konversationsverlauf**: Pro Discord-Channel werden die letzten 10 Frage-Antwort-Paare in-memory gehalten und als History an `brain.AskQuery()` übergeben
- **Task-Felder**: `DueDate *time.Time` und `Priority string` (hoch/mittel/niedrig) — rückwärtskompatibel (omitempty)
- **Email processed_folder**: Nach Zusammenfassung werden ungelesene Emails in konfigurierten IMAP-Ordner verschoben (leer = deaktiviert)
- **Morgen-Briefing**: `dailyBriefing()` kombiniert offene Tasks (mit Fälligkeits-Highlighting) + ungelesene Emails täglich um 8:00
## config.yml Neue Felder
```yaml
email:
processed_folder: "Processed" # Zielordner nach Zusammenfassung (leer = kein Verschieben)
daemon:
task_reminder_hour: 8 # Uhrzeit des Morgen-Briefings (Standard: 8)
```
## Deployment
```bash
# deploy.env (nicht in Git):
DEPLOY_HOST=192.168.1.118
DEPLOY_USER=christoph
DEPLOY_PASS=...
DEPLOY_DIR=/home/christoph/brain-bot
SERVICE_NAME=brain-bot
DEPLOY_CONFIG=true
# Systemd-Service (einmalig):
sudo systemctl unmask brain-bot # falls masked
```
Script `deploy.sh` baut das Linux-Binary, überträgt es per `sshpass`/`scp` und startet den systemd-Service neu.
## Model Limitations

View File

@@ -23,6 +23,7 @@ import (
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/brain"
"my-brain-importer/internal/config"
"my-brain-importer/internal/diag"
)
// maxHistoryPairs ist die maximale Anzahl gespeicherter Gesprächspaare pro Channel.
@@ -220,6 +221,7 @@ func main() {
defer dg.Close()
registerCommands()
runStartupDiag()
sendWelcomeMessage()
go startDaemon()
@@ -480,6 +482,21 @@ func getAuthorFromMessage(m *discordgo.MessageCreate) string {
return "unknown"
}
// runStartupDiag prüft alle externen Dienste und loggt + sendet das Ergebnis in den Daemon-Channel.
func runStartupDiag() {
results, allOK := diag.RunAll()
diag.Log(results)
channelID := config.Cfg.Daemon.ChannelID
if channelID == "" {
return
}
msg := diag.Format(results, allOK)
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
log.Printf("⚠️ Diagnose-Nachricht konnte nicht gesendet werden: %v", err)
}
}
// sendWelcomeMessage schickt beim Bot-Start eine Begrüßung in den konfigurierten Daemon-Channel.
func sendWelcomeMessage() {
channelID := config.Cfg.Daemon.ChannelID

View File

@@ -35,9 +35,9 @@ scp_cmd() {
sshpass -p "$DEPLOY_PASS" scp $SSH_OPTS "$@"
}
# sudo ohne TTY: Passwort per stdin mit -S
# sudo ohne TTY: Passwort per stdin (-S), -p '' unterdrückt den Prompt auf stderr
sudo_cmd() {
ssh_cmd "echo '${SUDO_PASS}' | sudo -S $*"
ssh_cmd "echo '${SUDO_PASS}' | sudo -S -p '' $*"
}
# ── Build ────────────────────────────────────────────────────────────────────
@@ -47,11 +47,18 @@ echo "✅ Binary gebaut"
# ── Dateien übertragen ───────────────────────────────────────────────────────
echo "📁 Zielverzeichnis ${DEPLOY_DIR} auf ${DEPLOY_HOST}..."
ssh_cmd "mkdir -p '${DEPLOY_DIR}'"
if ! ssh_cmd "mkdir -p ${DEPLOY_DIR}"; then
echo "❌ Verzeichnis konnte nicht erstellt werden prüfe Rechte auf ${DEPLOY_HOST}"
exit 1
fi
echo "🚀 Übertrage Binary..."
scp_cmd bin/discord-linux "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/discord"
ssh_cmd "chmod +x '${DEPLOY_DIR}/discord'"
# Als temporäre Datei hochladen, dann atomar ersetzen (laufendes Binary kann nicht überschrieben werden)
if ! scp_cmd bin/discord-linux "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/discord.new"; then
echo "❌ SCP fehlgeschlagen"
exit 1
fi
ssh_cmd "chmod +x '${DEPLOY_DIR}/discord.new' && mv '${DEPLOY_DIR}/discord.new' '${DEPLOY_DIR}/discord'"
if [[ "$DEPLOY_CONFIG" == "true" ]]; then
echo "📋 Übertrage config.yml..."

View File

@@ -0,0 +1,43 @@
package agents
import "testing"
func TestHistoryMessage_Fields(t *testing.T) {
h := HistoryMessage{Role: "user", Content: "Hallo"}
if h.Role != "user" {
t.Errorf("Role: %q", h.Role)
}
if h.Content != "Hallo" {
t.Errorf("Content: %q", h.Content)
}
}
func TestRequest_HistoryAppend(t *testing.T) {
req := Request{
Action: ActionQuery,
Args: []string{"Frage"},
History: []HistoryMessage{
{Role: "user", Content: "Vorherige Frage"},
{Role: "assistant", Content: "Vorherige Antwort"},
},
}
if len(req.History) != 2 {
t.Errorf("History len: %d", len(req.History))
}
if req.History[0].Role != "user" {
t.Errorf("erstes Element: %q", req.History[0].Role)
}
}
func TestResponse_RawAnswer(t *testing.T) {
resp := Response{
Text: "**Formatierte** Antwort",
RawAnswer: "Formatierte Antwort",
}
if resp.RawAnswer == "" {
t.Error("RawAnswer sollte gesetzt sein")
}
if resp.Text == resp.RawAnswer {
t.Error("Text und RawAnswer sollten sich unterscheiden")
}
}

View File

@@ -0,0 +1,236 @@
package task
import (
"os"
"strings"
"testing"
"time"
"my-brain-importer/internal/agents"
)
func newTestAgent(t *testing.T) (*Agent, func()) {
t.Helper()
f, err := os.CreateTemp("", "tasks_agent_*.json")
if err != nil {
t.Fatalf("temp-Datei: %v", err)
}
name := f.Name()
f.Close()
os.Remove(name) // Datei entfernen → loadLocked gibt leeres Slice zurück
a := &Agent{store: &Store{path: name}}
return a, func() { os.Remove(name) }
}
// ── parseAddArgs ─────────────────────────────────────────────────────────────
func TestParseAddArgs_TextOnly(t *testing.T) {
text, prio, due := parseAddArgs([]string{"Arzttermin", "buchen"})
if text != "Arzttermin buchen" {
t.Errorf("text: %q", text)
}
if prio != "" {
t.Errorf("prio sollte leer sein: %q", prio)
}
if due != nil {
t.Error("due sollte nil sein")
}
}
func TestParseAddArgs_AllFlags(t *testing.T) {
text, prio, due := parseAddArgs([]string{"Zahnarzt", "--due", "2026-12-01", "--priority", "hoch"})
if text != "Zahnarzt" {
t.Errorf("text: %q", text)
}
if prio != "hoch" {
t.Errorf("prio: %q", prio)
}
if due == nil {
t.Fatal("due sollte gesetzt sein")
}
if due.Format("2006-01-02") != "2026-12-01" {
t.Errorf("due: %v", due)
}
}
func TestParseAddArgs_ShortFlags(t *testing.T) {
text, prio, due := parseAddArgs([]string{"Meeting", "-p", "mittel", "-d", "2026-06-15"})
if text != "Meeting" {
t.Errorf("text: %q", text)
}
if prio != "mittel" {
t.Errorf("prio: %q", prio)
}
if due == nil || due.Format("2006-01-02") != "2026-06-15" {
t.Errorf("due: %v", due)
}
}
func TestParseAddArgs_InvalidDate(t *testing.T) {
_, _, due := parseAddArgs([]string{"Task", "--due", "kein-datum"})
if due != nil {
t.Error("ungültiges Datum sollte nil ergeben")
}
}
// ── Agent.Handle ─────────────────────────────────────────────────────────────
func TestAgent_Add(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Mein Task"}})
if resp.Error != nil {
t.Fatalf("Add: %v", resp.Error)
}
if !strings.Contains(resp.Text, "Mein Task") {
t.Errorf("Antwort enthält keinen Task-Text: %q", resp.Text)
}
}
func TestAgent_Add_WithDueAndPriority(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{
Action: agents.ActionAdd,
Args: []string{"Frist", "--due", "2026-12-31", "--priority", "hoch"},
})
if resp.Error != nil {
t.Fatalf("Add: %v", resp.Error)
}
if !strings.Contains(resp.Text, "Frist") {
t.Errorf("Task-Text fehlt: %q", resp.Text)
}
if !strings.Contains(resp.Text, "hoch") {
t.Errorf("Priorität fehlt: %q", resp.Text)
}
if !strings.Contains(resp.Text, "31.12.2026") {
t.Errorf("Datum fehlt: %q", resp.Text)
}
}
func TestAgent_Add_NoArgs(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{}})
if !strings.Contains(resp.Text, "❌") {
t.Errorf("erwartet Fehlermeldung, got: %q", resp.Text)
}
}
func TestAgent_List_Empty(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: agents.ActionList})
if !strings.Contains(resp.Text, "Keine Tasks") {
t.Errorf("erwartet 'Keine Tasks': %q", resp.Text)
}
}
func TestAgent_List_ShowsDueDate(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
due := time.Now().Add(48 * time.Hour) // übermorgen
a.store.Add("Übermorgen", "", &due)
resp := a.Handle(agents.Request{Action: agents.ActionList})
if !strings.Contains(resp.Text, "📅") {
t.Errorf("Datum-Icon fehlt in Liste: %q", resp.Text)
}
}
func TestAgent_List_ShowsOverdue(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
past := time.Now().Add(-48 * time.Hour) // vorgestern
a.store.Add("Überfällig", "", &past)
resp := a.Handle(agents.Request{Action: agents.ActionList})
if !strings.Contains(resp.Text, "ÜBERFÄLLIG") {
t.Errorf("ÜBERFÄLLIG fehlt: %q", resp.Text)
}
}
func TestAgent_Done(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
addResp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Erledigen"}})
// Short-ID aus Antwort extrahieren
tasks, _ := a.store.Load()
if len(tasks) == 0 {
t.Fatal("kein Task angelegt")
}
id := tasks[0].ID
shortID := id[len(id)-6:]
_ = addResp
resp := a.Handle(agents.Request{Action: agents.ActionDone, Args: []string{shortID}})
if resp.Error != nil {
t.Fatalf("Done: %v", resp.Error)
}
if !strings.Contains(resp.Text, "✅") {
t.Errorf("erwartet ✅: %q", resp.Text)
}
}
func TestAgent_Delete(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Löschen"}})
tasks, _ := a.store.Load()
id := tasks[0].ID
shortID := id[len(id)-6:]
resp := a.Handle(agents.Request{Action: agents.ActionDelete, Args: []string{shortID}})
if resp.Error != nil {
t.Fatalf("Delete: %v", resp.Error)
}
if !strings.Contains(resp.Text, "🗑️") {
t.Errorf("erwartet 🗑️: %q", resp.Text)
}
tasks, _ = a.store.Load()
if len(tasks) != 0 {
t.Errorf("Task sollte gelöscht sein, noch %d vorhanden", len(tasks))
}
}
func TestAgent_UnknownAction(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: "unknown"})
if !strings.Contains(resp.Text, "❌") {
t.Errorf("erwartet Fehlermeldung: %q", resp.Text)
}
}
// ── resolveID ────────────────────────────────────────────────────────────────
func TestResolveID_FullMatch(t *testing.T) {
tasks := []Task{{ID: "123456789"}}
if got := resolveID(tasks, "123456789"); got != "123456789" {
t.Errorf("got %q", got)
}
}
func TestResolveID_ShortMatch(t *testing.T) {
tasks := []Task{{ID: "123456789"}}
if got := resolveID(tasks, "456789"); got != "123456789" {
t.Errorf("got %q", got)
}
}
func TestResolveID_NotFound(t *testing.T) {
tasks := []Task{{ID: "123456789"}}
if got := resolveID(tasks, "000000"); got != "" {
t.Errorf("erwartet leer, got %q", got)
}
}

View File

@@ -0,0 +1,169 @@
package task
import (
"os"
"testing"
"time"
)
func newTestStore(t *testing.T) (*Store, func()) {
t.Helper()
f, err := os.CreateTemp("", "tasks_test_*.json")
if err != nil {
t.Fatalf("temp-Datei erstellen: %v", err)
}
name := f.Name()
f.Close()
os.Remove(name) // Datei entfernen → loadLocked gibt leeres Slice zurück
s := &Store{path: name}
return s, func() { os.Remove(name) }
}
func TestStore_AddAndLoad(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
task, err := s.Add("Test Task", "hoch", nil)
if err != nil {
t.Fatalf("Add: %v", err)
}
if task.Text != "Test Task" {
t.Errorf("Text: got %q, want %q", task.Text, "Test Task")
}
if task.Priority != "hoch" {
t.Errorf("Priority: got %q, want %q", task.Priority, "hoch")
}
if task.Done {
t.Error("neuer Task sollte nicht Done sein")
}
tasks, err := s.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("len: got %d, want 1", len(tasks))
}
}
func TestStore_AddWithDueDate(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
due := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
task, err := s.Add("Frist-Task", "mittel", &due)
if err != nil {
t.Fatalf("Add: %v", err)
}
if task.DueDate == nil {
t.Fatal("DueDate sollte gesetzt sein")
}
if !task.DueDate.Equal(due) {
t.Errorf("DueDate: got %v, want %v", task.DueDate, due)
}
}
func TestStore_MarkDone(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
task, _ := s.Add("Erledigbarer Task", "", nil)
if err := s.MarkDone(task.ID); err != nil {
t.Fatalf("MarkDone: %v", err)
}
tasks, _ := s.Load()
if !tasks[0].Done {
t.Error("Task sollte Done=true sein")
}
if tasks[0].DoneAt == nil {
t.Error("DoneAt sollte gesetzt sein")
}
}
func TestStore_MarkDone_NotFound(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
err := s.MarkDone("nichtexistent")
if err == nil {
t.Error("erwartet Fehler für unbekannte ID")
}
}
func TestStore_Delete(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
t1, _ := s.Add("Task 1", "", nil)
s.Add("Task 2", "", nil)
if err := s.Delete(t1.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
tasks, _ := s.Load()
if len(tasks) != 1 {
t.Fatalf("nach Delete: got %d, want 1", len(tasks))
}
if tasks[0].Text != "Task 2" {
t.Errorf("falscher Task verblieben: %q", tasks[0].Text)
}
}
func TestStore_Delete_NotFound(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
err := s.Delete("nichtexistent")
if err == nil {
t.Error("erwartet Fehler für unbekannte ID")
}
}
func TestStore_OpenTasks(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
t1, _ := s.Add("Offen", "", nil)
s.Add("Auch offen", "", nil)
s.MarkDone(t1.ID)
open, err := s.OpenTasks()
if err != nil {
t.Fatalf("OpenTasks: %v", err)
}
if len(open) != 1 {
t.Fatalf("OpenTasks: got %d, want 1", len(open))
}
if open[0].Text != "Auch offen" {
t.Errorf("falscher offener Task: %q", open[0].Text)
}
}
func TestStore_EmptyFile(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
tasks, err := s.Load()
if err != nil {
t.Fatalf("Load auf leerer Datei: %v", err)
}
if len(tasks) != 0 {
t.Errorf("erwartet leer, got %d", len(tasks))
}
}
func TestStore_Idempotent_MultipleAdds(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
s.Add("A", "", nil)
s.Add("B", "niedrig", nil)
s.Add("C", "hoch", nil)
tasks, _ := s.Load()
if len(tasks) != 3 {
t.Fatalf("erwartet 3 Tasks, got %d", len(tasks))
}
}

View File

@@ -0,0 +1,72 @@
package email
import (
"strings"
"testing"
"time"
)
var testMessages = []Message{
{Subject: "Rechnung März", From: "buchhaltung@firma.de", Date: "2026-03-01 09:00"},
{Subject: "Meeting Einladung", From: "chef@firma.de", Date: "2026-03-02 10:30"},
{Subject: "Newsletter", From: "news@shop.de", Date: "2026-03-03 08:00"},
}
func TestFormatEmailList_ContainsFields(t *testing.T) {
result := formatEmailList(testMessages)
checks := []string{"Rechnung März", "buchhaltung@firma.de", "Meeting Einladung", "Newsletter"}
for _, s := range checks {
if !strings.Contains(result, s) {
t.Errorf("formatEmailList: fehlt %q in:\n%s", s, result)
}
}
}
func TestFormatEmailList_NumberedLines(t *testing.T) {
result := formatEmailList(testMessages)
if !strings.Contains(result, "[1]") {
t.Error("fehlt [1] in Ausgabe")
}
if !strings.Contains(result, "[3]") {
t.Error("fehlt [3] in Ausgabe")
}
}
func TestFormatEmailList_Empty(t *testing.T) {
result := formatEmailList([]Message{})
if result != "" {
t.Errorf("leere Liste sollte leeren String ergeben, got: %q", result)
}
}
func TestFallbackList_ContainsWarning(t *testing.T) {
result := fallbackList(testMessages)
if !strings.Contains(result, "LLM nicht verfügbar") {
t.Errorf("Fallback-Hinweis fehlt: %q", result)
}
}
func TestFallbackList_ContainsAllMessages(t *testing.T) {
result := fallbackList(testMessages)
for _, m := range testMessages {
if !strings.Contains(result, m.Subject) {
t.Errorf("Betreff fehlt: %q", m.Subject)
}
}
}
func TestParseMessages_Empty(t *testing.T) {
result := parseMessages(nil)
if len(result) != 0 {
t.Errorf("erwartet leer, got %d", len(result))
}
}
func TestMessage_DateFormat(t *testing.T) {
// Datum muss im Format "2006-01-02 15:04" formatiert werden
m := testMessages[0]
if _, err := time.Parse("2006-01-02 15:04", m.Date); err != nil {
t.Errorf("Datumsformat ungültig: %v", err)
}
}

120
internal/diag/diag.go Normal file
View File

@@ -0,0 +1,120 @@
// diag Start-Diagnose: prüft Erreichbarkeit aller externen Dienste
package diag
import (
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
"my-brain-importer/internal/config"
)
// Result ist das Ergebnis einer einzelnen Prüfung.
type Result struct {
Name string
OK bool
Message string
}
// RunAll führt alle Verbindungs-Checks durch und gibt eine Zusammenfassung zurück.
// warnings = Dienste die konfiguriert aber nicht erreichbar sind.
func RunAll() (results []Result, allOK bool) {
allOK = true
cfg := config.Cfg
check := func(name string, ok bool, msg string) {
results = append(results, Result{Name: name, OK: ok, Message: msg})
if !ok {
allOK = false
}
}
// Qdrant
qdrantAddr := fmt.Sprintf("%s:%s", cfg.Qdrant.Host, cfg.Qdrant.Port)
ok, msg := tcpCheck(qdrantAddr)
check("Qdrant ("+qdrantAddr+")", ok, msg)
// LocalAI Chat
if cfg.Chat.URL != "" {
ok, msg = httpCheck(cfg.Chat.URL)
check("LocalAI Chat ("+cfg.Chat.URL+")", ok, msg)
}
// LocalAI Embedding (nur prüfen wenn andere URL als Chat)
if cfg.Embedding.URL != "" && cfg.Embedding.URL != cfg.Chat.URL {
ok, msg = httpCheck(cfg.Embedding.URL)
check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg)
}
// IMAP
if cfg.Email.Host != "" {
imapAddr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
ok, msg = tcpCheck(imapAddr)
check("IMAP ("+imapAddr+")", ok, msg)
}
return results, allOK
}
// Log gibt die Ergebnisse über slog aus.
func Log(results []Result) {
for _, r := range results {
if r.OK {
slog.Info("Dienst-Check", "dienst", r.Name, "status", "OK", "info", r.Message)
} else {
slog.Warn("Dienst-Check", "dienst", r.Name, "status", "FEHLER", "info", r.Message)
}
}
}
// Format gibt eine menschenlesbare Zusammenfassung zurück (für Discord/stdout).
func Format(results []Result, allOK bool) string {
var sb strings.Builder
sb.WriteString("🔍 **Start-Diagnose:**\n")
for _, r := range results {
icon := "✅"
if !r.OK {
icon = "❌"
}
fmt.Fprintf(&sb, "%s %s %s\n", icon, r.Name, r.Message)
}
if allOK {
sb.WriteString("\n✅ Alle Dienste erreichbar.")
} else {
sb.WriteString("\n⚠ Einige Dienste sind nicht erreichbar — Bot läuft, aber Funktionen könnten fehlen.")
}
return sb.String()
}
func tcpCheck(addr string) (bool, string) {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false, "nicht erreichbar: " + err.Error()
}
conn.Close()
return true, "TCP OK"
}
func httpCheck(baseURL string) (bool, string) {
// Normalisiere URL: entferne trailing /v1 etc., hänge /v1/models an
url := strings.TrimRight(baseURL, "/")
if !strings.HasSuffix(url, "/models") {
// Gehe zur Basis-URL zurück und frage /v1/models
if idx := strings.LastIndex(url, "/v1"); idx >= 0 {
url = url[:idx]
}
url += "/v1/models"
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return false, "nicht erreichbar: " + err.Error()
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK,
fmt.Sprintf("HTTP %d", resp.StatusCode)
}

134
test-integration.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# test-integration.sh Prüft Erreichbarkeit aller externen Dienste
# Kann manuell oder automatisch beim Bot-Start aufgerufen werden.
#
# Exit-Code: 0 = alle Dienste OK, 1 = mindestens ein Dienst nicht erreichbar
set -euo pipefail
CONFIG_FILE="${1:-./config.yml}"
TIMEOUT=5
ERRORS=0
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $*"; }
fail() { echo -e " ${RED}${NC} $*"; ((ERRORS++)); }
warn() { echo -e " ${YELLOW}${NC} $*"; }
# ── Konfiguration lesen ──────────────────────────────────────────────────────
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "❌ config.yml nicht gefunden: $CONFIG_FILE"
exit 1
fi
read_yaml() {
python3 -c "
import yaml, sys
with open('$CONFIG_FILE') as f:
cfg = yaml.safe_load(f)
keys = '$1'.split('.')
val = cfg
for k in keys:
val = val.get(k, '') if isinstance(val, dict) else ''
print(val or '')
" 2>/dev/null || echo ""
}
QDRANT_HOST=$(read_yaml qdrant.host)
QDRANT_PORT=$(read_yaml qdrant.port)
CHAT_URL=$(read_yaml chat.url)
EMB_URL=$(read_yaml embedding.url)
IMAP_HOST=$(read_yaml email.host)
IMAP_PORT=$(read_yaml email.port)
echo ""
echo "🔍 Integrations-Diagnose"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── Qdrant ───────────────────────────────────────────────────────────────────
echo ""
echo "📦 Qdrant (${QDRANT_HOST}:${QDRANT_PORT})"
if nc -z -w "$TIMEOUT" "$QDRANT_HOST" "$QDRANT_PORT" 2>/dev/null; then
ok "TCP-Verbindung erfolgreich"
else
fail "TCP-Verbindung fehlgeschlagen (${QDRANT_HOST}:${QDRANT_PORT})"
fi
# ── LocalAI (Chat) ───────────────────────────────────────────────────────────
echo ""
echo "🤖 LocalAI Chat (${CHAT_URL})"
if [[ -n "$CHAT_URL" ]]; then
MODELS_URL="${CHAT_URL%/v1*}/v1/models"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$MODELS_URL" 2>/dev/null || echo "000")
if [[ "$HTTP_CODE" == "200" ]]; then
ok "HTTP ${HTTP_CODE} erreichbar"
elif [[ "$HTTP_CODE" == "000" ]]; then
fail "Keine Verbindung (Timeout/Refused)"
else
warn "HTTP ${HTTP_CODE} unerwartet aber erreichbar"
fi
else
warn "chat.url nicht konfiguriert"
fi
# ── LocalAI (Embedding) ──────────────────────────────────────────────────────
echo ""
echo "🔢 LocalAI Embedding (${EMB_URL})"
if [[ -n "$EMB_URL" && "$EMB_URL" != "$CHAT_URL" ]]; then
MODELS_URL="${EMB_URL%/v1*}/v1/models"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$MODELS_URL" 2>/dev/null || echo "000")
if [[ "$HTTP_CODE" == "200" ]]; then
ok "HTTP ${HTTP_CODE} erreichbar"
elif [[ "$HTTP_CODE" == "000" ]]; then
fail "Keine Verbindung (Timeout/Refused)"
else
warn "HTTP ${HTTP_CODE}"
fi
else
ok "Gleicher Endpunkt wie Chat übersprungen"
fi
# ── IMAP ────────────────────────────────────────────────────────────────────
echo ""
echo "📧 IMAP (${IMAP_HOST}:${IMAP_PORT})"
if [[ -n "$IMAP_HOST" && -n "$IMAP_PORT" ]]; then
if nc -z -w "$TIMEOUT" "$IMAP_HOST" "$IMAP_PORT" 2>/dev/null; then
ok "TCP-Verbindung erfolgreich"
else
fail "TCP-Verbindung fehlgeschlagen (${IMAP_HOST}:${IMAP_PORT})"
fi
else
warn "IMAP nicht konfiguriert übersprungen"
fi
# ── Unit-Tests ───────────────────────────────────────────────────────────────
echo ""
echo "🧪 Go Unit-Tests"
if go test ./... -count=1 -timeout 30s 2>&1 | grep -E "^(ok|FAIL|---)" | while read -r line; do
if echo "$line" | grep -q "^ok"; then
ok "$line"
elif echo "$line" | grep -q "^FAIL\|^--- FAIL"; then
fail "$line"
fi
done; then
:
fi
# Prüfe Exit-Code der Tests separat
if ! go test ./... -count=1 -timeout 30s > /dev/null 2>&1; then
((ERRORS++))
fi
# ── Ergebnis ────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "$ERRORS" -eq 0 ]]; then
echo -e "${GREEN}✅ Alle Checks bestanden${NC}"
exit 0
else
echo -e "${RED}${ERRORS} Check(s) fehlgeschlagen${NC}"
exit 1
fi