auto deployment und tests

This commit is contained in:
Christoph K.
2026-03-20 07:07:38 +01:00
parent 0e7aa3e7f2
commit 8163f906cc
12 changed files with 500 additions and 66 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
bin/ bin/
config.yml config.yml
deploy.env
ask ask
discord discord

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -24,6 +25,9 @@ import (
"my-brain-importer/internal/config" "my-brain-importer/internal/config"
) )
// maxHistoryPairs ist die maximale Anzahl gespeicherter Gesprächspaare pro Channel.
const maxHistoryPairs = 10
var ( var (
dg *discordgo.Session dg *discordgo.Session
botUser *discordgo.User botUser *discordgo.User
@@ -34,6 +38,10 @@ var (
taskAgent agents.Agent taskAgent agents.Agent
toolAgent agents.Agent toolAgent agents.Agent
// Konversationsverlauf pro Channel (in-memory, wird bei Neustart zurückgesetzt).
historyMu sync.Mutex
historyCache = make(map[string][]agents.HistoryMessage)
commands = []*discordgo.ApplicationCommand{ commands = []*discordgo.ApplicationCommand{
{ {
Name: "ask", Name: "ask",
@@ -96,6 +104,8 @@ var (
Description: "Neuen Task hinzufügen", Description: "Neuen Task hinzufügen",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Task", Required: true}, {Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Task", Required: true},
{Type: discordgo.ApplicationCommandOptionString, Name: "faellig", Description: "Fälligkeitsdatum (YYYY-MM-DD)", Required: false},
{Type: discordgo.ApplicationCommandOptionString, Name: "prioritaet", Description: "Priorität: hoch, mittel, niedrig", Required: false},
}, },
}, },
{ {
@@ -145,6 +155,29 @@ var (
} }
) )
// getHistory gibt den gespeicherten Gesprächsverlauf für einen Channel zurück.
func getHistory(channelID string) []agents.HistoryMessage {
historyMu.Lock()
defer historyMu.Unlock()
msgs := historyCache[channelID]
result := make([]agents.HistoryMessage, len(msgs))
copy(result, msgs)
return result
}
// addToHistory speichert eine Nachricht im Gesprächsverlauf eines Channels.
func addToHistory(channelID, role, content string) {
historyMu.Lock()
defer historyMu.Unlock()
msgs := historyCache[channelID]
msgs = append(msgs, agents.HistoryMessage{Role: role, Content: content})
// Maximal maxHistoryPairs Paare (= maxHistoryPairs*2 Nachrichten) behalten
if len(msgs) > maxHistoryPairs*2 {
msgs = msgs[len(msgs)-maxHistoryPairs*2:]
}
historyCache[channelID] = msgs
}
func main() { func main() {
config.LoadConfig() config.LoadConfig()
@@ -187,6 +220,7 @@ func main() {
defer dg.Close() defer dg.Close()
registerCommands() registerCommands()
sendWelcomeMessage()
go startDaemon() go startDaemon()
fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.") fmt.Println("✅ Bot läuft. Drücke CTRL+C zum Beenden.")
@@ -227,8 +261,18 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch data.Name { switch data.Name {
case "ask", "research": case "ask", "research":
question := data.Options[0].StringValue() question := data.Options[0].StringValue()
channelID := i.ChannelID
handleAgentResponse(s, i, func() agents.Response { handleAgentResponse(s, i, func() agents.Response {
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: []string{question}}) resp := researchAgent.Handle(agents.Request{
Action: agents.ActionQuery,
Args: []string{question},
History: getHistory(channelID),
})
if resp.RawAnswer != "" {
addToHistory(channelID, "user", question)
addToHistory(channelID, "assistant", resp.RawAnswer)
}
return resp
}) })
case "asknobrain": case "asknobrain":
@@ -277,10 +321,24 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
sub := i.ApplicationCommandData().Options[0] sub := i.ApplicationCommandData().Options[0]
req := agents.Request{Action: sub.Name} // sub.Name ist bereits der Action-String (add/list/done/delete) req := agents.Request{Action: sub.Name}
if len(sub.Options) > 0 {
if sub.Name == "add" && len(sub.Options) > 0 {
// Baue args mit Text + optionalen Flags für due/priority
args := []string{sub.Options[0].StringValue()}
for _, opt := range sub.Options[1:] {
switch opt.Name {
case "faellig":
args = append(args, "--due", opt.StringValue())
case "prioritaet":
args = append(args, "--priority", opt.StringValue())
}
}
req.Args = args
} else if len(sub.Options) > 0 {
req.Args = []string{sub.Options[0].StringValue()} req.Args = []string{sub.Options[0].StringValue()}
} }
handleAgentResponse(s, i, func() agents.Response { handleAgentResponse(s, i, func() agents.Response {
return taskAgent.Handle(req) return taskAgent.Handle(req)
}) })
@@ -350,7 +408,7 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question) slog.Info("Mention", "user", m.Author.Username, "channel", m.ChannelID, "nachricht", question)
s.ChannelTyping(m.ChannelID) s.ChannelTyping(m.ChannelID)
resp := routeMessage(question, getAuthorFromMessage(m)) resp := routeMessage(question, getAuthorFromMessage(m), m.ChannelID)
if resp.Error != nil { if resp.Error != nil {
slog.Error("Mention-Fehler", "fehler", resp.Error) slog.Error("Mention-Fehler", "fehler", resp.Error)
} }
@@ -368,10 +426,10 @@ func SendMessage(channelID, text string) error {
// routeMessage leitet eine @mention-Nachricht an den passenden Agenten weiter. // routeMessage leitet eine @mention-Nachricht an den passenden Agenten weiter.
// Unterstützte Präfixe: "email ...", "task ...", "remember ...", sonst Research. // Unterstützte Präfixe: "email ...", "task ...", "remember ...", sonst Research.
func routeMessage(text, author string) agents.Response { func routeMessage(text, author, channelID string) agents.Response {
words := strings.Fields(text) words := strings.Fields(text)
if len(words) == 0 { 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>`"} return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add <text> [--due YYYY-MM-DD] [--priority hoch]`, `task done <id>`, `remember <text>`"}
} }
cmd := strings.ToLower(words[0]) cmd := strings.ToLower(words[0])
@@ -402,7 +460,16 @@ func routeMessage(text, author string) agents.Response {
}) })
default: default:
return researchAgent.Handle(agents.Request{Action: agents.ActionQuery, Args: words}) resp := researchAgent.Handle(agents.Request{
Action: agents.ActionQuery,
Args: words,
History: getHistory(channelID),
})
if resp.RawAnswer != "" {
addToHistory(channelID, "user", text)
addToHistory(channelID, "assistant", resp.RawAnswer)
}
return resp
} }
} }
@@ -413,6 +480,44 @@ func getAuthorFromMessage(m *discordgo.MessageCreate) string {
return "unknown" return "unknown"
} }
// sendWelcomeMessage schickt beim Bot-Start eine Begrüßung in den konfigurierten Daemon-Channel.
func sendWelcomeMessage() {
channelID := config.Cfg.Daemon.ChannelID
if channelID == "" {
return
}
msg := `🤖 **Brain-Bot ist online!**
**Slash-Commands:**
` + "```" + `
/ask <frage> Wissensdatenbank abfragen
/research <frage> Alias für /ask
/asknobrain <frage> Direkt ans LLM (kein RAG)
/memory store <text> Text in Wissensdatenbank speichern
/memory ingest Markdown-Notizen neu einlesen
/task add <text> [--due YYYY-MM-DD] [--priority hoch|mittel|niedrig]
/task list Alle Tasks anzeigen
/task done <id> Task erledigen
/task delete <id> Task löschen
/email summary Letzte Emails zusammenfassen
/email unread Ungelesene Emails zusammenfassen
/email remind Termine aus Emails extrahieren
` + "```" + `
**@Mention:**
` + "```" + `
@Brain <frage> Wissensdatenbank (mit Chat-Gedächtnis)
@Brain task add <text> [--due ...] [--priority ...]
@Brain task list / done / delete
@Brain email summary / unread / remind
@Brain remember <text>
` + "```" + `
⚙️ Daemon aktiv: Email-Check + tägliches Morgen-Briefing`
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err)
}
}
func getAuthor(i *discordgo.InteractionCreate) string { func getAuthor(i *discordgo.InteractionCreate) string {
if i.Member != nil { if i.Member != nil {
return i.Member.User.Username return i.Member.User.Username
@@ -445,8 +550,8 @@ func startDaemon() {
emailTicker := time.NewTicker(emailInterval) emailTicker := time.NewTicker(emailInterval)
defer emailTicker.Stop() defer emailTicker.Stop()
taskTimer := scheduleDaily(reminderHour, 0) briefingTimer := scheduleDaily(reminderHour, 0)
defer taskTimer.Stop() defer briefingTimer.Stop()
for { for {
select { select {
@@ -469,29 +574,11 @@ func startDaemon() {
slog.Info("Daemon: Keine neuen Emails") slog.Info("Daemon: Keine neuen Emails")
} }
case <-taskTimer.C: case <-briefingTimer.C:
slog.Info("Daemon: Task-Reminder gestartet") slog.Info("Daemon: Morgen-Briefing gestartet")
store := task.NewStore() dailyBriefing(channelID)
open, err := store.OpenTasks() briefingTimer.Stop()
if err != nil { briefingTimer = scheduleDaily(reminderHour, 0)
slog.Error("Daemon Task-Fehler", "fehler", err)
continue
}
if len(open) > 0 {
var sb strings.Builder
fmt.Fprintf(&sb, "📋 **Tägliche Task-Erinnerung** %d offene Tasks:\n\n", len(open))
for _, t := range open {
shortID := t.ID
if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:]
}
fmt.Fprintf(&sb, "⬜ `%s` %s\n", shortID, t.Text)
}
dg.ChannelMessageSend(channelID, sb.String())
slog.Info("Daemon: Task-Reminder gesendet", "offene_tasks", len(open))
}
taskTimer.Stop()
taskTimer = scheduleDaily(reminderHour, 0)
} }
} }
} }
@@ -504,6 +591,63 @@ func scheduleDaily(hour, minute int) *time.Timer {
next = next.Add(24 * time.Hour) next = next.Add(24 * time.Hour)
} }
d := next.Sub(now) d := next.Sub(now)
log.Printf("⏰ Nächster Task-Reminder in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute) log.Printf("⏰ Nächstes Morgen-Briefing in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute)
return time.NewTimer(d) return time.NewTimer(d)
} }
// dailyBriefing sendet eine kombinierte Morgen-Zusammenfassung: offene Tasks + ungelesene Emails.
func dailyBriefing(channelID string) {
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
// Tasks
var taskSection strings.Builder
store := task.NewStore()
open, err := store.OpenTasks()
if err != nil {
slog.Error("Daemon Briefing Task-Fehler", "fehler", err)
} else if len(open) > 0 {
fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open))
for _, t := range open {
shortID := t.ID
if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:]
}
urgency := ""
if t.DueDate != nil {
due := t.DueDate.Truncate(24 * time.Hour)
switch {
case due.Before(today):
urgency = " ⚠️ **ÜBERFÄLLIG**"
case due.Equal(today):
urgency = " 🔴 *heute fällig*"
case due.Equal(tomorrow):
urgency = " 🟡 *morgen fällig*"
}
}
fmt.Fprintf(&taskSection, "⬜ `%s` %s%s\n", shortID, t.Text, urgency)
}
}
// Emails
var emailSection string
notify, err := email.SummarizeUnread()
if err != nil {
slog.Error("Daemon Briefing Email-Fehler", "fehler", err)
} else if notify != "📭 Keine ungelesenen Emails." {
emailSection = "\n\n📧 **Ungelesene Emails:**\n" + notify
}
var msg strings.Builder
fmt.Fprintf(&msg, "☀️ **Morgen-Briefing** %s\n\n", time.Now().Format("02.01.2006"))
if taskSection.Len() > 0 {
msg.WriteString(taskSection.String())
}
msg.WriteString(emailSection)
if taskSection.Len() == 0 && emailSection == "" {
msg.WriteString("✨ Keine offenen Tasks und keine ungelesenen Emails. Guten Morgen!")
}
dg.ChannelMessageSend(channelID, msg.String())
slog.Info("Daemon: Morgen-Briefing gesendet", "offene_tasks", len(open))
}

16
deploy.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Deploy-Konfiguration Kopieren als deploy.env und anpassen
# deploy.env wird NICHT in Git eingecheckt
DEPLOY_HOST=192.168.1.118
DEPLOY_USER=todo
DEPLOY_PASS=geheim
# Zielverzeichnis auf dem Server
DEPLOY_DIR=/home/jacek/brain-bot
# Systemd-Servicename
SERVICE_NAME=brain-bot
# config.yml mitdeployen? (true/false)
# false = config.yml bleibt auf dem Server unangetastet
DEPLOY_CONFIG=true

100
deploy.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# deploy.sh Baut den Bot und deployt ihn per SSH auf den Server
set -euo pipefail
# Credentials aus deploy.env laden
ENV_FILE="$(dirname "$0")/deploy.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "❌ deploy.env nicht gefunden. Kopiere deploy.env.example nach deploy.env und passe sie an."
exit 1
fi
source "$ENV_FILE"
: "${DEPLOY_HOST:?DEPLOY_HOST fehlt in deploy.env}"
: "${DEPLOY_USER:?DEPLOY_USER fehlt in deploy.env}"
: "${DEPLOY_PASS:?DEPLOY_PASS fehlt in deploy.env}"
: "${DEPLOY_DIR:=/home/${DEPLOY_USER}/brain-bot}"
: "${SERVICE_NAME:=brain-bot}"
: "${DEPLOY_CONFIG:=true}"
# Sudo-Passwort: standardmäßig gleich wie SSH-Passwort
SUDO_PASS="${SUDO_PASS:-$DEPLOY_PASS}"
SSH_OPTS="-o StrictHostKeyChecking=no -o BatchMode=no"
if ! command -v sshpass &>/dev/null; then
echo "❌ sshpass nicht installiert: sudo apt install sshpass"
exit 1
fi
ssh_cmd() {
sshpass -p "$DEPLOY_PASS" ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" "$@"
}
scp_cmd() {
sshpass -p "$DEPLOY_PASS" scp $SSH_OPTS "$@"
}
# sudo ohne TTY: Passwort per stdin mit -S
sudo_cmd() {
ssh_cmd "echo '${SUDO_PASS}' | sudo -S $*"
}
# ── Build ────────────────────────────────────────────────────────────────────
echo "🔨 Baue Linux-Binary (amd64)..."
GOOS=linux GOARCH=amd64 go build -o bin/discord-linux ./cmd/discord/
echo "✅ Binary gebaut"
# ── Dateien übertragen ───────────────────────────────────────────────────────
echo "📁 Zielverzeichnis ${DEPLOY_DIR} auf ${DEPLOY_HOST}..."
ssh_cmd "mkdir -p '${DEPLOY_DIR}'"
echo "🚀 Übertrage Binary..."
scp_cmd bin/discord-linux "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/discord"
ssh_cmd "chmod +x '${DEPLOY_DIR}/discord'"
if [[ "$DEPLOY_CONFIG" == "true" ]]; then
echo "📋 Übertrage config.yml..."
scp_cmd config.yml "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/config.yml"
fi
# ── Systemd-Service ──────────────────────────────────────────────────────────
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
if ! ssh_cmd "test -f '${SERVICE_FILE}'" 2>/dev/null; then
echo "📝 Installiere systemd-Service..."
# Service-Datei zuerst in /tmp schreiben (kein sudo nötig), dann verschieben
TMP_SERVICE="/tmp/${SERVICE_NAME}.service"
ssh_cmd "cat > '${TMP_SERVICE}'" <<EOF
[Unit]
Description=my-brain-importer Discord Bot
After=network.target
[Service]
ExecStart=${DEPLOY_DIR}/discord
WorkingDirectory=${DEPLOY_DIR}
Restart=on-failure
RestartSec=5s
User=${DEPLOY_USER}
[Install]
WantedBy=multi-user.target
EOF
sudo_cmd "mv '${TMP_SERVICE}' '${SERVICE_FILE}'"
sudo_cmd "systemctl daemon-reload"
sudo_cmd "systemctl enable '${SERVICE_NAME}'"
echo "✅ Service installiert und aktiviert"
fi
# ── Neustart ─────────────────────────────────────────────────────────────────
echo "🔄 Starte Service neu..."
# unmask falls der Service als masked markiert ist
sudo_cmd "systemctl unmask '${SERVICE_NAME}'" 2>/dev/null || true
sudo_cmd "systemctl restart '${SERVICE_NAME}'"
echo ""
echo "✅ Deployment abgeschlossen!"
echo " Status: ssh ${DEPLOY_USER}@${DEPLOY_HOST} 'systemctl status ${SERVICE_NAME}'"
echo " Logs: ssh ${DEPLOY_USER}@${DEPLOY_HOST} 'journalctl -u ${SERVICE_NAME} -f'"

View File

@@ -1,18 +1,26 @@
// agent.go Gemeinsames Interface für alle Agenten // agent.go Gemeinsames Interface für alle Agenten
package agents package agents
// HistoryMessage repräsentiert eine vorherige Konversationsnachricht.
type HistoryMessage struct {
Role string // "user" oder "assistant"
Content string
}
// Request enthält die Eingabe für einen Agenten. // Request enthält die Eingabe für einen Agenten.
type Request struct { type Request struct {
Action string // z.B. "store", "list", "done", "summary" Action string // z.B. "store", "list", "done", "summary"
Args []string // Argumente für die Aktion Args []string // Argumente für die Aktion
Author string // Discord-Username (für Kontext) Author string // Discord-Username (für Kontext)
Source string // Herkunft (z.B. "discord/#channelID") Source string // Herkunft (z.B. "discord/#channelID")
History []HistoryMessage // Konversationsverlauf (für Chat-Gedächtnis)
} }
// Response enthält die Ausgabe eines Agenten. // Response enthält die Ausgabe eines Agenten.
type Response struct { type Response struct {
Text string // Formattierte Antwort Text string // Formattierte Antwort
Error error // Fehler, falls aufgetreten Error error // Fehler, falls aufgetreten
RawAnswer string // Unformatierte LLM-Antwort (für Konversationsverlauf)
} }
// Agent ist das gemeinsame Interface für alle Agenten. // Agent ist das gemeinsame Interface für alle Agenten.

View File

@@ -20,7 +20,7 @@ func (a *Agent) Handle(req agents.Request) agents.Response {
} }
question := strings.Join(req.Args, " ") question := strings.Join(req.Args, " ")
answer, chunks, err := brain.AskQuery(question) answer, chunks, err := brain.AskQuery(question, req.History)
if err != nil { if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)} return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
} }
@@ -35,5 +35,5 @@ func (a *Agent) Handle(req agents.Request) agents.Response {
for _, chunk := range chunks { for _, chunk := range chunks {
fmt.Fprintf(&sb, "• %.1f%% %s\n", chunk.Score*100, chunk.Source) fmt.Fprintf(&sb, "• %.1f%% %s\n", chunk.Score*100, chunk.Source)
} }
return agents.Response{Text: sb.String()} return agents.Response{Text: sb.String(), RawAnswer: answer}
} }

View File

@@ -4,6 +4,7 @@ package task
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"my-brain-importer/internal/agents" "my-brain-importer/internal/agents"
) )
@@ -34,12 +35,41 @@ func (a *Agent) Handle(req agents.Request) agents.Response {
} }
} }
// parseAddArgs parst Text, --due YYYY-MM-DD und --priority WERT aus den Args.
func parseAddArgs(args []string) (text, priority string, dueDate *time.Time) {
var textParts []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--due", "-d":
i++
if i < len(args) {
t, err := time.Parse("2006-01-02", args[i])
if err == nil {
dueDate = &t
}
}
case "--priority", "-p":
i++
if i < len(args) {
priority = strings.ToLower(args[i])
}
default:
textParts = append(textParts, args[i])
}
}
text = strings.Join(textParts, " ")
return
}
func (a *Agent) add(req agents.Request) agents.Response { func (a *Agent) add(req agents.Request) agents.Response {
if len(req.Args) == 0 { if len(req.Args) == 0 {
return agents.Response{Text: "❌ Kein Task-Text angegeben."} return agents.Response{Text: "❌ Kein Task-Text angegeben."}
} }
text := strings.Join(req.Args, " ") text, priority, dueDate := parseAddArgs(req.Args)
t, err := a.store.Add(text) if text == "" {
return agents.Response{Text: "❌ Kein Task-Text angegeben."}
}
t, err := a.store.Add(text, priority, dueDate)
if err != nil { if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)} return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
} }
@@ -47,7 +77,18 @@ func (a *Agent) add(req agents.Request) agents.Response {
if len(shortID) > 6 { if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:] shortID = shortID[len(shortID)-6:]
} }
return agents.Response{Text: fmt.Sprintf("✅ Task hinzugefügt: `%s` (ID: `%s`)", t.Text, shortID)} var extras []string
if t.Priority != "" {
extras = append(extras, "Priorität: "+t.Priority)
}
if t.DueDate != nil {
extras = append(extras, "Fällig: "+t.DueDate.Format("02.01.2006"))
}
info := ""
if len(extras) > 0 {
info = " (" + strings.Join(extras, ", ") + ")"
}
return agents.Response{Text: fmt.Sprintf("✅ Task hinzugefügt: `%s`%s (ID: `%s`)", t.Text, info, shortID)}
} }
func (a *Agent) list() agents.Response { func (a *Agent) list() agents.Response {
@@ -59,6 +100,9 @@ func (a *Agent) list() agents.Response {
return agents.Response{Text: "📋 Keine Tasks vorhanden."} return agents.Response{Text: "📋 Keine Tasks vorhanden."}
} }
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
var sb strings.Builder var sb strings.Builder
sb.WriteString("📋 **Task-Liste:**\n\n") sb.WriteString("📋 **Task-Liste:**\n\n")
openCount := 0 openCount := 0
@@ -73,7 +117,39 @@ func (a *Agent) list() agents.Response {
if len(shortID) > 6 { if len(shortID) > 6 {
shortID = shortID[len(shortID)-6:] shortID = shortID[len(shortID)-6:]
} }
fmt.Fprintf(&sb, "%s `%s` %s\n", status, shortID, t.Text)
var meta []string
if t.Priority != "" {
switch t.Priority {
case "hoch":
meta = append(meta, "🔴 hoch")
case "mittel":
meta = append(meta, "🟡 mittel")
case "niedrig":
meta = append(meta, "🟢 niedrig")
default:
meta = append(meta, t.Priority)
}
}
if t.DueDate != nil && !t.Done {
due := t.DueDate.Truncate(24 * time.Hour)
switch {
case due.Before(today):
meta = append(meta, "⏰ **ÜBERFÄLLIG** "+t.DueDate.Format("02.01."))
case due.Equal(today):
meta = append(meta, "⏰ heute fällig")
case due.Equal(tomorrow):
meta = append(meta, "📅 morgen fällig")
default:
meta = append(meta, "📅 "+t.DueDate.Format("02.01.2006"))
}
}
line := fmt.Sprintf("%s `%s` %s", status, shortID, t.Text)
if len(meta) > 0 {
line += " · " + strings.Join(meta, " · ")
}
sb.WriteString(line + "\n")
} }
fmt.Fprintf(&sb, "\n*%d offen, %d gesamt*", openCount, len(tasks)) fmt.Fprintf(&sb, "\n*%d offen, %d gesamt*", openCount, len(tasks))
return agents.Response{Text: sb.String()} return agents.Response{Text: sb.String()}

View File

@@ -18,6 +18,8 @@ type Task struct {
Done bool `json:"done"` Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
DoneAt *time.Time `json:"done_at,omitempty"` DoneAt *time.Time `json:"done_at,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
Priority string `json:"priority,omitempty"` // "hoch", "mittel", "niedrig"
} }
// Store verwaltet tasks.json mit atomischen Schreiboperationen. // Store verwaltet tasks.json mit atomischen Schreiboperationen.
@@ -69,7 +71,7 @@ func (s *Store) save(tasks []Task) error {
} }
// Add fügt einen neuen Task hinzu. // Add fügt einen neuen Task hinzu.
func (s *Store) Add(text string) (Task, error) { func (s *Store) Add(text, priority string, dueDate *time.Time) (Task, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -83,6 +85,8 @@ func (s *Store) Add(text string) (Task, error) {
Text: text, Text: text,
Done: false, Done: false,
CreatedAt: time.Now(), CreatedAt: time.Now(),
Priority: priority,
DueDate: dueDate,
} }
tasks = append(tasks, t) tasks = append(tasks, t)

View File

@@ -126,6 +126,52 @@ func (cl *Client) FetchUnread() ([]Message, error) {
return parseMessages(msgs), nil return parseMessages(msgs), nil
} }
// FetchUnreadSeqNums holt ungelesene Emails und gibt zusätzlich die Sequenznummern zurück.
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
func (cl *Client) FetchUnreadSeqNums() ([]Message, []uint32, error) {
folder := config.Cfg.Email.Folder
if folder == "" {
folder = "INBOX"
}
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
return nil, 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, nil, fmt.Errorf("IMAP search: %w", err)
}
seqNums := searchData.AllSeqNums()
if len(seqNums) == 0 {
return nil, 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, nil, fmt.Errorf("IMAP fetch: %w", err)
}
return parseMessages(msgs), seqNums, nil
}
// MoveMessages verschiebt Nachrichten in einen anderen IMAP-Ordner.
// Der Ordner muss im Lese-Schreib-Modus selektiert sein (via FetchUnreadSeqNums).
func (cl *Client) MoveMessages(seqNums []uint32, destFolder string) error {
var seqSet imap.SeqSet
seqSet.AddNum(seqNums...)
if _, err := cl.c.Move(seqSet, destFolder).Wait(); err != nil {
return fmt.Errorf("IMAP move: %w", err)
}
return nil
}
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message { func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
result := make([]Message, 0, len(msgs)) result := make([]Message, 0, len(msgs))
for _, msg := range msgs { for _, msg := range msgs {

View File

@@ -19,6 +19,7 @@ func Summarize() (string, error) {
} }
// SummarizeUnread fasst ungelesene Emails zusammen. // SummarizeUnread fasst ungelesene Emails zusammen.
// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben.
func SummarizeUnread() (string, error) { func SummarizeUnread() (string, error) {
cl, err := Connect() cl, err := Connect()
if err != nil { if err != nil {
@@ -26,7 +27,16 @@ func SummarizeUnread() (string, error) {
} }
defer cl.Close() defer cl.Close()
msgs, err := cl.FetchUnread() processedFolder := config.Cfg.Email.ProcessedFolder
var msgs []Message
var seqNums []uint32
if processedFolder != "" {
msgs, seqNums, err = cl.FetchUnreadSeqNums()
} else {
msgs, err = cl.FetchUnread()
}
if err != nil { if err != nil {
return "", fmt.Errorf("Emails abrufen: %w", err) return "", fmt.Errorf("Emails abrufen: %w", err)
} }
@@ -35,7 +45,21 @@ func SummarizeUnread() (string, error) {
} }
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread") 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.") result, err := summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
if err != nil {
return "", err
}
// Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben
if processedFolder != "" && len(seqNums) > 0 {
if moveErr := cl.MoveMessages(seqNums, processedFolder); moveErr != nil {
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", processedFolder)
} else {
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", processedFolder)
}
}
return result, nil
} }
// ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines. // ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines.

View File

@@ -13,6 +13,7 @@ import (
openai "github.com/sashabaranov/go-openai" openai "github.com/sashabaranov/go-openai"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/config" "my-brain-importer/internal/config"
) )
@@ -25,7 +26,8 @@ type KnowledgeChunk struct {
// AskQuery sucht relevante Chunks und generiert eine LLM-Antwort. // AskQuery sucht relevante Chunks und generiert eine LLM-Antwort.
// Gibt die Antwort als String und die verwendeten Quellen zurück. // Gibt die Antwort als String und die verwendeten Quellen zurück.
func AskQuery(question string) (string, []KnowledgeChunk, error) { // history enthält vorherige Gesprächsnachrichten (optional, nil für stateless).
func AskQuery(question string, history []agents.HistoryMessage) (string, []KnowledgeChunk, error) {
ctx := context.Background() ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey) ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
@@ -58,17 +60,29 @@ Basierend auf diesen Informationen, beantworte bitte folgende Frage:
slog.Debug("[LLM] AskQuery Prompt", slog.Debug("[LLM] AskQuery Prompt",
"model", config.Cfg.Chat.Model, "model", config.Cfg.Chat.Model,
"history_len", len(history),
"system", systemPrompt, "system", systemPrompt,
"user", userPrompt, "user", userPrompt,
) )
msgs := []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
}
for _, h := range history {
msgs = append(msgs, openai.ChatCompletionMessage{
Role: h.Role,
Content: h.Content,
})
}
msgs = append(msgs, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: userPrompt,
})
start := time.Now() 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: msgs,
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
Temperature: 0.7, Temperature: 0.7,
MaxTokens: 500, MaxTokens: 500,
}) })
@@ -102,7 +116,7 @@ func Ask(question string) {
fmt.Printf("🤔 Frage: \"%s\"\n\n", question) fmt.Printf("🤔 Frage: \"%s\"\n\n", question)
fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...") fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...")
answer, chunks, err := AskQuery(question) answer, chunks, err := AskQuery(question, nil)
if err != nil { if err != nil {
log.Fatalf("❌ %v", err) log.Fatalf("❌ %v", err)
} }

View File

@@ -44,6 +44,7 @@ type Config struct {
TLS bool `yaml:"tls"` TLS bool `yaml:"tls"`
StartTLS bool `yaml:"starttls"` StartTLS bool `yaml:"starttls"`
Folder string `yaml:"folder"` Folder string `yaml:"folder"`
ProcessedFolder string `yaml:"processed_folder"` // Zielordner nach Zusammenfassung (leer = kein Verschieben)
Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen
} `yaml:"email"` } `yaml:"email"`