tests
This commit is contained in:
60
CLAUDE.md
60
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
17
deploy.sh
17
deploy.sh
@@ -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..."
|
||||
|
||||
43
internal/agents/agent_test.go
Normal file
43
internal/agents/agent_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
236
internal/agents/task/agent_test.go
Normal file
236
internal/agents/task/agent_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
169
internal/agents/task/store_test.go
Normal file
169
internal/agents/task/store_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
72
internal/agents/tool/email/summary_test.go
Normal file
72
internal/agents/tool/email/summary_test.go
Normal 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
120
internal/diag/diag.go
Normal 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
134
test-integration.sh
Executable 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
|
||||
Reference in New Issue
Block a user