From 8163f906ccf7be4ba64556de6b8f46e117c3c26f Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Fri, 20 Mar 2026 07:07:38 +0100 Subject: [PATCH] auto deployment und tests --- .gitignore | 1 + cmd/discord/main.go | 216 +++++++++++++++++++++----- deploy.env.example | 16 ++ deploy.sh | 100 ++++++++++++ internal/agents/agent.go | 20 ++- internal/agents/research/agent.go | 4 +- internal/agents/task/agent.go | 84 +++++++++- internal/agents/task/store.go | 6 +- internal/agents/tool/email/client.go | 46 ++++++ internal/agents/tool/email/summary.go | 28 +++- internal/brain/ask.go | 28 +++- internal/config/config.go | 17 +- 12 files changed, 500 insertions(+), 66 deletions(-) create mode 100644 deploy.env.example create mode 100755 deploy.sh diff --git a/.gitignore b/.gitignore index e3b2f11..15fb57d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ config.yml +deploy.env ask discord diff --git a/cmd/discord/main.go b/cmd/discord/main.go index e85ce5d..814cb28 100644 --- a/cmd/discord/main.go +++ b/cmd/discord/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "strings" + "sync" "syscall" "time" @@ -24,16 +25,23 @@ import ( "my-brain-importer/internal/config" ) +// maxHistoryPairs ist die maximale Anzahl gespeicherter Gesprächspaare pro Channel. +const maxHistoryPairs = 10 + var ( - dg *discordgo.Session - botUser *discordgo.User - daemonStop = make(chan struct{}) + dg *discordgo.Session + botUser *discordgo.User + daemonStop = make(chan struct{}) researchAgent agents.Agent memoryAgent agents.Agent taskAgent 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{ { Name: "ask", @@ -96,6 +104,8 @@ var ( Description: "Neuen Task hinzufügen", Options: []*discordgo.ApplicationCommandOption{ {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() { config.LoadConfig() @@ -187,6 +220,7 @@ func main() { defer dg.Close() registerCommands() + sendWelcomeMessage() go startDaemon() 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 { case "ask", "research": question := data.Options[0].StringValue() + channelID := i.ChannelID 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": @@ -277,10 +321,24 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { sub := i.ApplicationCommandData().Options[0] - req := agents.Request{Action: sub.Name} // sub.Name ist bereits der Action-String (add/list/done/delete) - if len(sub.Options) > 0 { + req := agents.Request{Action: sub.Name} + + 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()} } + handleAgentResponse(s, i, func() agents.Response { 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) s.ChannelTyping(m.ChannelID) - resp := routeMessage(question, getAuthorFromMessage(m)) + resp := routeMessage(question, getAuthorFromMessage(m), m.ChannelID) if resp.Error != nil { 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. // 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) if len(words) == 0 { - return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add `, `task done `, `remember `"} + return agents.Response{Text: "Stell mir eine Frage oder nutze: `email summary`, `email unread`, `email remind`, `task list`, `task add [--due YYYY-MM-DD] [--priority hoch]`, `task done `, `remember `"} } cmd := strings.ToLower(words[0]) @@ -402,7 +460,16 @@ func routeMessage(text, author string) agents.Response { }) 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" } +// 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 – Wissensdatenbank abfragen +/research – Alias für /ask +/asknobrain – Direkt ans LLM (kein RAG) +/memory store – Text in Wissensdatenbank speichern +/memory ingest – Markdown-Notizen neu einlesen +/task add [--due YYYY-MM-DD] [--priority hoch|mittel|niedrig] +/task list – Alle Tasks anzeigen +/task done – Task erledigen +/task delete – Task löschen +/email summary – Letzte Emails zusammenfassen +/email unread – Ungelesene Emails zusammenfassen +/email remind – Termine aus Emails extrahieren +` + "```" + ` +**@Mention:** +` + "```" + ` +@Brain – Wissensdatenbank (mit Chat-Gedächtnis) +@Brain task add [--due ...] [--priority ...] +@Brain task list / done / delete +@Brain email summary / unread / remind +@Brain remember +` + "```" + ` +⚙️ 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 { if i.Member != nil { return i.Member.User.Username @@ -445,8 +550,8 @@ func startDaemon() { emailTicker := time.NewTicker(emailInterval) defer emailTicker.Stop() - taskTimer := scheduleDaily(reminderHour, 0) - defer taskTimer.Stop() + briefingTimer := scheduleDaily(reminderHour, 0) + defer briefingTimer.Stop() for { select { @@ -469,29 +574,11 @@ func startDaemon() { slog.Info("Daemon: Keine neuen Emails") } - case <-taskTimer.C: - slog.Info("Daemon: Task-Reminder gestartet") - store := task.NewStore() - open, err := store.OpenTasks() - if err != nil { - 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) + case <-briefingTimer.C: + slog.Info("Daemon: Morgen-Briefing gestartet") + dailyBriefing(channelID) + briefingTimer.Stop() + briefingTimer = scheduleDaily(reminderHour, 0) } } } @@ -504,6 +591,63 @@ func scheduleDaily(hour, minute int) *time.Timer { next = next.Add(24 * time.Hour) } d := next.Sub(now) - log.Printf("⏰ Nächster Task-Reminder in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute) + log.Printf("⏰ Nächstes Morgen-Briefing in %v (um %02d:%02d)", d.Round(time.Minute), hour, minute) 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)) +} diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..fa659df --- /dev/null +++ b/deploy.env.example @@ -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 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..2ebb120 --- /dev/null +++ b/deploy.sh @@ -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}'" </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'" diff --git a/internal/agents/agent.go b/internal/agents/agent.go index 5286eeb..e846f66 100644 --- a/internal/agents/agent.go +++ b/internal/agents/agent.go @@ -1,18 +1,26 @@ // agent.go – Gemeinsames Interface für alle Agenten 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. type Request struct { - Action string // z.B. "store", "list", "done", "summary" - Args []string // Argumente für die Aktion - Author string // Discord-Username (für Kontext) - Source string // Herkunft (z.B. "discord/#channelID") + Action string // z.B. "store", "list", "done", "summary" + Args []string // Argumente für die Aktion + Author string // Discord-Username (für Kontext) + Source string // Herkunft (z.B. "discord/#channelID") + History []HistoryMessage // Konversationsverlauf (für Chat-Gedächtnis) } // Response enthält die Ausgabe eines Agenten. type Response struct { - Text string // Formattierte Antwort - Error error // Fehler, falls aufgetreten + Text string // Formattierte Antwort + Error error // Fehler, falls aufgetreten + RawAnswer string // Unformatierte LLM-Antwort (für Konversationsverlauf) } // Agent ist das gemeinsame Interface für alle Agenten. diff --git a/internal/agents/research/agent.go b/internal/agents/research/agent.go index 0bcb806..4f3a129 100644 --- a/internal/agents/research/agent.go +++ b/internal/agents/research/agent.go @@ -20,7 +20,7 @@ func (a *Agent) Handle(req agents.Request) agents.Response { } question := strings.Join(req.Args, " ") - answer, chunks, err := brain.AskQuery(question) + answer, chunks, err := brain.AskQuery(question, req.History) if err != nil { 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 { 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} } diff --git a/internal/agents/task/agent.go b/internal/agents/task/agent.go index 7ea4811..bda981f 100644 --- a/internal/agents/task/agent.go +++ b/internal/agents/task/agent.go @@ -4,6 +4,7 @@ package task import ( "fmt" "strings" + "time" "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 { if len(req.Args) == 0 { return agents.Response{Text: "❌ Kein Task-Text angegeben."} } - text := strings.Join(req.Args, " ") - t, err := a.store.Add(text) + text, priority, dueDate := parseAddArgs(req.Args) + if text == "" { + return agents.Response{Text: "❌ Kein Task-Text angegeben."} + } + t, err := a.store.Add(text, priority, dueDate) if err != nil { 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 { 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 { @@ -59,6 +100,9 @@ func (a *Agent) list() agents.Response { return agents.Response{Text: "📋 Keine Tasks vorhanden."} } + today := time.Now().Truncate(24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + var sb strings.Builder sb.WriteString("📋 **Task-Liste:**\n\n") openCount := 0 @@ -73,7 +117,39 @@ func (a *Agent) list() agents.Response { if 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)) return agents.Response{Text: sb.String()} diff --git a/internal/agents/task/store.go b/internal/agents/task/store.go index 8469d1e..243aca1 100644 --- a/internal/agents/task/store.go +++ b/internal/agents/task/store.go @@ -18,6 +18,8 @@ type Task struct { Done bool `json:"done"` CreatedAt time.Time `json:"created_at"` 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. @@ -69,7 +71,7 @@ func (s *Store) save(tasks []Task) error { } // 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() defer s.mu.Unlock() @@ -83,6 +85,8 @@ func (s *Store) Add(text string) (Task, error) { Text: text, Done: false, CreatedAt: time.Now(), + Priority: priority, + DueDate: dueDate, } tasks = append(tasks, t) diff --git a/internal/agents/tool/email/client.go b/internal/agents/tool/email/client.go index 0fe6164..5ed152e 100644 --- a/internal/agents/tool/email/client.go +++ b/internal/agents/tool/email/client.go @@ -126,6 +126,52 @@ func (cl *Client) FetchUnread() ([]Message, error) { 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 { result := make([]Message, 0, len(msgs)) for _, msg := range msgs { diff --git a/internal/agents/tool/email/summary.go b/internal/agents/tool/email/summary.go index 4e3c6bb..33bf4fd 100644 --- a/internal/agents/tool/email/summary.go +++ b/internal/agents/tool/email/summary.go @@ -19,6 +19,7 @@ func Summarize() (string, error) { } // SummarizeUnread fasst ungelesene Emails zusammen. +// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben. func SummarizeUnread() (string, error) { cl, err := Connect() if err != nil { @@ -26,7 +27,16 @@ func SummarizeUnread() (string, error) { } 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 { 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") - 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. diff --git a/internal/brain/ask.go b/internal/brain/ask.go index a5e5249..239181f 100755 --- a/internal/brain/ask.go +++ b/internal/brain/ask.go @@ -13,6 +13,7 @@ import ( openai "github.com/sashabaranov/go-openai" "google.golang.org/grpc/metadata" + "my-brain-importer/internal/agents" "my-brain-importer/internal/config" ) @@ -25,7 +26,8 @@ type KnowledgeChunk struct { // 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) { +// 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 = 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", "model", config.Cfg.Chat.Model, + "history_len", len(history), "system", systemPrompt, "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() stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ - Model: config.Cfg.Chat.Model, - Messages: []openai.ChatCompletionMessage{ - {Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, - {Role: openai.ChatMessageRoleUser, Content: userPrompt}, - }, + Model: config.Cfg.Chat.Model, + Messages: msgs, Temperature: 0.7, MaxTokens: 500, }) @@ -102,7 +116,7 @@ func Ask(question string) { fmt.Printf("🤔 Frage: \"%s\"\n\n", question) fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...") - answer, chunks, err := AskQuery(question) + answer, chunks, err := AskQuery(question, nil) if err != nil { log.Fatalf("❌ %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index e12042e..fd46ac9 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,14 +37,15 @@ type Config struct { } `yaml:"discord"` Email struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - User string `yaml:"user"` - Password string `yaml:"password"` - TLS bool `yaml:"tls"` - StartTLS bool `yaml:"starttls"` - Folder string `yaml:"folder"` - Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + TLS bool `yaml:"tls"` + StartTLS bool `yaml:"starttls"` + Folder string `yaml:"folder"` + ProcessedFolder string `yaml:"processed_folder"` // Zielordner nach Zusammenfassung (leer = kein Verschieben) + Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen } `yaml:"email"` Tasks struct {