auto deployment und tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
bin/
|
||||
config.yml
|
||||
deploy.env
|
||||
ask
|
||||
discord
|
||||
|
||||
@@ -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 <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])
|
||||
@@ -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 <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 {
|
||||
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))
|
||||
}
|
||||
|
||||
16
deploy.env.example
Normal file
16
deploy.env.example
Normal 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
100
deploy.sh
Executable 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'"
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user