zwischenstand

This commit is contained in:
Christoph K.
2026-03-20 23:24:56 +01:00
parent b1a576f61e
commit 905981cd1e
25 changed files with 3607 additions and 217 deletions

102
CLAUDE.md
View File

@@ -48,11 +48,13 @@ cmd/discord/main.go
├── internal/agents/research/ → brain.AskQuery() + Konversationsverlauf
├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage()
├── internal/agents/task/ → tasks.json (atomisches JSON, DueDate + Priority)
└── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung + Move to Processed
└── internal/agents/tool/email/ → IMAP + LLM-Triage + Zusammenfassung + Move to Archive + Cleanup
[Daemon-Goroutine] startDaemon()
├── Email-Check (alle N min) → #localagent Discord-Channel
── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert
├── IMAP IDLE (pro Account) → Echtzeit-Email-Benachrichtigung + LLM-Triage
── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert
├── Archiv-Aufräumen (täglich 2h) → CleanupArchiveFolders() nach retention_days
└── Nacht-Ingest (täglich 23h) → brain.IngestEmailFolder() für alle Archiv-Ordner
cmd/ingest/ + cmd/ask/ (CLI-Tools, direkt nutzbar)
@@ -76,7 +78,9 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
| `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 + Move to Processed |
| `internal/agents/tool/email/` | IMAP-Client + LLM-Triage + Email-Analyse + IDLE-Watcher + Move to Archive + CleanupOldEmails |
| `internal/agents/tool/rss/` | RSS-Feed-Watcher: Feeds fetchen, Artikel in Qdrant importieren, Daemon-Integration |
| `internal/triage/` | RAG-basiertes Lernen: Triage-Entscheidungen in Qdrant speichern + suchen (eigenes Package um Import-Zyklus brain↔email zu vermeiden) |
### Discord Commands
@@ -86,11 +90,21 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
| `/asknobrain` | | Direkt an LLM (kein RAG) |
| `/memory store` | `@bot remember <text>` | Text speichern |
| `/memory ingest` | `@bot ingest` | Markdown neu einlesen |
| `/memory url <url>` | | URL-Inhalt in Wissensdatenbank importieren |
| `/memory profile <text>` | | Fakt zum Kerngedächtnis hinzufügen (wird in jeden LLM-Prompt eingebaut) |
| `/memory profile-show` | | Kerngedächtnis anzeigen |
| `/knowledge list` | | Gespeicherte Quellen auflisten |
| `/knowledge delete <source>` | | Quelle aus Wissensdatenbank löschen |
| `/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 |
| `/email ingest [ordner]` | `@bot email ingest [ordner]` | Emails aus IMAP-Ordner in Wissensdatenbank importieren (Standard: Archiv) |
| `/email move <ordner>` | `@bot email move <ordner>` | Ungelesene Emails in Archivordner verschieben (Choices dynamisch aus `archive_folders`) |
| `/status` | | Bot-Gesundheit: alle Dienste + offene Tasks |
| `/clear` | `@bot clear` | Gesprächsverlauf für diesen Channel löschen |
| `/remember` | | Alias für `/memory store` |
| `/ingest` | | Alias für `/memory ingest` |
| *(PDF-Anhang)* | `@bot` + PDF-Datei | PDF direkt per Discord importieren |
## Key Patterns
@@ -102,19 +116,91 @@ 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
- **Konversationsverlauf**: Pro Discord-Channel werden die letzten 10 Frage-Antwort-Paare in-memory gehalten und als History an `brain.AskQuery()` übergeben — Reset via `/clear` oder `@bot clear`
- **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
- **Archiv-Cleanup**: `email.CleanupArchiveFolders()` läuft täglich um `cleanup_hour` (Standard: 2:00) — iteriert alle Accounts/`archive_folders`, löscht Emails älter als `retention_days` via IMAP `\Deleted` + `EXPUNGE`. `retention_days: 0` = dauerhaft behalten (No-op).
- **Email-Triage**: `email.triageUnread()` klassifiziert ungelesene Emails sequentiell (eine nach der anderen) als wichtig/unwichtig via LLM. Unwichtige Emails werden in `triage_folder` verschoben. Jede Entscheidung wird in Qdrant gespeichert (`type: email_triage`). Bei nächster Klassifizierung sucht `triage.SearchSimilar()` ähnliche Entscheidungen (Score ≥ 0.7) als Few-Shot-Kontext — das Modell lernt aus der Geschichte. Triage läuft vor `SummarizeUnreadAccount()`.
- **Nacht-Ingest**: `nightlyIngest()` läuft täglich um `ingest_hour` (Standard: 23:00) — importiert alle Emails aller Archiv-Ordner in Qdrant via `brain.IngestEmailFolder()`.
- **User-Permissions**: `discord.allowed_users: ["user-id1", "user-id2"]` — wenn gesetzt, dürfen nur diese Discord-User-IDs den Bot nutzen. Leer = keine Einschränkung.
- **URL-Ingest**: `brain.IngestURL(url)` — fetcht URL, extrahiert sichtbaren HTML-Text (skippt script/style/nav/footer), chunked und importiert in Qdrant. Via `/memory url <url>`.
- **PDF-Ingest**: `brain.IngestPDF(path, source)` — extrahiert Text aus PDF via `github.com/ledongthuc/pdf`, chunked und importiert. Trigger: PDF-Anhang an @Bot-Mention.
- **Knowledge Management**: `brain.ListSources()` (Qdrant Scroll) + `brain.DeleteBySource()` (Qdrant Filter-Delete). Via `/knowledge list` und `/knowledge delete <source>`.
- **Core Memory**: `brain_root/core_memory.md` — persistente Fakten über den Nutzer. `brain.LoadCoreMemory()` wird in `AskQuery()` in den System-Prompt eingefügt. Via `/memory profile <text>` und `/memory profile-show`.
- **RSS-Watcher**: `rss.Watcher` — fetcht alle `rss_feeds` aus Config, importiert neue Artikel via `brain.IngestText()`. Läuft als Goroutine im Daemon.
- **IngestText**: `brain.IngestText(text, source, type)` — generische Ingest-Funktion für beliebige Texte (kein Datei-I/O nötig).
- **Dynamische /email move Choices**: `patchEmailMoveChoices()` wird in `main()` nach `config.LoadConfig()` aufgerufen und ersetzt die statischen Discord-Choices mit konfigurierten `archive_folders`. Fallback auf Legacy-Hardcoding (`2Jahre`/`5Jahre`/`Archiv`) wenn keine `archive_folders` konfiguriert.
- **Archive folder resolution**: `resolveArchiveFolder(name)` in `tool/agent.go` sucht case-insensitiv in `acc.ArchiveFolders` (Name oder IMAPFolder), dann Legacy-Fallback. Gilt für Slash-Commands und `@bot email move <name>`.
- **IMAP IDLE**: `email.IdleWatcher` pro Account — Echtzeit-Benachrichtigung bei neuen Emails, kein Polling mehr. Race-sichere Implementierung mit `atomic.Uint32` für `numMsgs`. Automatischer Reconnect nach 60s bei Fehler.
- **Mehrere Email-Accounts**: `config.AllEmailAccounts()` gibt alle Accounts zurück — zuerst `email_accounts:` (Liste), Fallback auf Legacy `email:` Block. Alle Email-Funktionen iterieren über alle Accounts.
- **`/status`**: Ruft `diag.RunAll()` auf + Task-Zähler — zeigt Echtzeit-Status aller externen Dienste
## config.yml Neue Felder
## config.yml Alle Felder
```yaml
# Einzelner Email-Account (Legacy, abwärtskompatibel):
email:
processed_folder: "Processed" # Zielordner nach Zusammenfassung (leer = kein Verschieben)
host: imap.strato.de
port: 143
user: user@example.de
password: "..."
starttls: true # oder tls: true für implizites TLS (Port 993)
folder: INBOX # optional, Standard: INBOX
processed_folder: "Processed" # nach Zusammenfassung verschieben (leer = deaktiviert)
triage_folder: "Unwichtig" # LLM-Triage: unwichtige Emails hier ablegen (leer = deaktiviert)
model: "" # optional: eigenes LLM-Modell für Email-Analyse
archive_folders: # optional: Archivordner mit automatischer Bereinigung
- name: "Archiv"
imap_folder: "Archiv"
retention_days: 0 # 0 = dauerhaft behalten (kein Cleanup)
- name: "5Jahre"
imap_folder: "5Jahre"
retention_days: 1825
- name: "2Jahre"
imap_folder: "2Jahre"
retention_days: 730
# Mehrere Email-Accounts (hat Vorrang vor email:):
email_accounts:
- name: "Privat"
host: imap.strato.de
port: 143
starttls: true
user: privat@example.de
password: "..."
processed_folder: "Processed"
archive_folders: # optional, wie im email:-Block oben
- name: "Archiv"
imap_folder: "Archiv"
retention_days: 0
- name: "Arbeit"
host: imap.firma.de
port: 993
tls: true
user: jacek@firma.de
password: "..."
daemon:
task_reminder_hour: 8 # Uhrzeit des Morgen-Briefings (Standard: 8)
channel_id: "123456789" # Discord-Channel für Daemon-Nachrichten
email_interval_min: 30 # (veraltet, IDLE ersetzt Polling)
task_reminder_hour: 8 # Uhrzeit des Morgen-Briefings (Standard: 8)
cleanup_hour: 2 # Uhrzeit des täglichen Archiv-Aufräumens (Standard: 2)
ingest_hour: 23 # Uhrzeit des nächtlichen Email-Ingests (Standard: 23)
# Discord User-Permissions (optional):
discord:
token: "..."
guild_id: "..." # optional: nur für diese Guild registrieren
allowed_users: # optional: leer = alle dürfen den Bot nutzen
- "123456789" # Discord User-ID
# RSS-Feeds (optional):
rss_feeds:
- url: "https://example.com/feed.xml"
interval_hours: 24 # Polling-Intervall (Standard: 24h)
- url: "https://news.example.de/rss"
interval_hours: 6
```
## Deployment

383
README.md
View File

@@ -1,43 +1,340 @@
# my-brain-importer
Persönlicher Wissens-Agent für den AI_Brain. Importiert Markdown-Notizen und Bildbeschreibungen in eine Qdrant-Vektordatenbank und beantwortet Fragen darüber mit einem lokalen LLM.
## Voraussetzungen
- Go 1.22+
- LocalAI läuft auf `embedding.url` mit dem konfigurierten Embedding-Modell geladen
- LocalAI läuft auf `chat.url` mit dem konfigurierten Chat-Modell geladen
- Qdrant läuft auf dem NAS (Port 6334 gRPC, Port 6333 Dashboard)
## Build
```bash
bash build.sh
```
Erzeugt `bin/ingest`, `bin/ingest.exe`, `bin/ask`, `bin/ask.exe`.
## Nutzung
```bash
# Markdown-Dateien aus brain_root importieren
./bin/ingest
# Alternatives Verzeichnis angeben
./bin/ingest /pfad/zum/verzeichnis
# Bildbeschreibungen aus JSON importieren
./bin/ingest image_descriptions.json
# Frage stellen
./bin/ask "Was sind meine Reisepläne für Norwegen?"
./bin/ask "Erzähl mir über Veronica Bellmore"
```
## Brain aktualisieren
Kein Löschen der Datenbank nötig — einfach `./bin/ingest` erneut ausführen:
- Bestehende Chunks → gleiche SHA256-ID → Qdrant überschreibt
- Neue Dateien → neue IDs → werden hinzugefügt
Architektur und Konfiguration: [doc/architecture.md](doc/architecture.md)
# Brain-Bot
Persönlicher KI-Assistent und RAG-System in Go. Speichert Wissen in einer Qdrant-Vektordatenbank und beantwortet Fragen über ein lokales LLM. Primäres Interface: Discord-Bot. Läuft als systemd-Dienst auf einem Home-Server.
## Features
- **Discord-Bot** mit Slash-Commands und @Mention
- **RAG** über Markdown-Notizen, Emails, URLs, PDFs und RSS-Artikel
- **Email-Management**: IMAP IDLE, Triage (wichtig/unwichtig via LLM), Archiv-Cleanup
- **Task-Verwaltung** mit Fälligkeit und Priorität
- **Morgen-Briefing** täglich um 8:00 (Tasks + ungelesene Emails)
- **Core Memory**: persistente Nutzerfakten, automatisch in jeden LLM-Prompt eingebaut
- **RSS-Watcher**: automatisches Importieren von Feed-Artikeln
- **User-Permissions**: optionale Einschränkung auf bestimmte Discord-User-IDs
---
## Voraussetzungen
| Dienst | Adresse | Zweck |
|--------|---------|-------|
| Qdrant | `192.168.1.4:6334` (gRPC) | Vektordatenbank |
| LocalAI | `192.168.1.118:8080` | Embeddings + Chat (OpenAI-kompatibel) |
| IMAP-Server | konfigurierbar | Email-Abruf (STARTTLS oder TLS) |
| Discord | Bot-Token | Primäres Interface |
---
## Schnellstart
```bash
# Einmalig: config.yml anlegen
cp config.yml.example config.yml # Credentials eintragen
# Bot starten
go run ./cmd/discord/
# Oder: CLI-Tools
go run ./cmd/ask/ "Was sind meine TODOs?"
go run ./cmd/ingest/ # Markdown aus brain_root importieren
```
---
## Discord-Commands
### Wissen abfragen
| Command | Beschreibung |
|---------|-------------|
| `/ask <frage>` | Wissensdatenbank abfragen (mit Gesprächsgedächtnis) |
| `/research <frage>` | Alias für `/ask` |
| `/asknobrain <frage>` | Direkt ans LLM, kein RAG |
| `/clear` | Gesprächsverlauf dieses Channels löschen |
### Wissen speichern
| Command | Beschreibung |
|---------|-------------|
| `/memory store <text>` | Text direkt in Wissensdatenbank speichern |
| `/memory ingest` | Alle Markdown-Dateien aus `brain_root` importieren |
| `/memory url <url>` | Webseite fetchen und importieren |
| `/memory profile <text>` | Fakt zum Kerngedächtnis hinzufügen |
| `/memory profile-show` | Kerngedächtnis anzeigen |
| `/remember <text>` | Alias für `/memory store` |
| `/ingest` | Alias für `/memory ingest` |
| *(PDF-Anhang)* | PDF an @Bot schicken → automatisch importiert |
### Wissensdatenbank verwalten
| Command | Beschreibung |
|---------|-------------|
| `/knowledge list` | Alle gespeicherten Quellen auflisten |
| `/knowledge delete <source>` | Quelle und alle ihre Chunks löschen |
### Tasks
| Command | Beschreibung |
|---------|-------------|
| `/task add <text> [--due YYYY-MM-DD] [--priority hoch\|mittel\|niedrig]` | Task anlegen |
| `/task list` | Alle offenen Tasks anzeigen |
| `/task done <id>` | Task als erledigt markieren |
| `/task delete <id>` | Task löschen |
### Email
| Command | Beschreibung |
|---------|-------------|
| `/email summary` | Letzte Emails zusammenfassen |
| `/email unread` | Ungelesene Emails zusammenfassen |
| `/email remind` | Termine und Deadlines aus Emails extrahieren |
| `/email ingest [ordner]` | Emails eines IMAP-Ordners in Qdrant importieren |
| `/email move <ordner>` | Emails interaktiv in Archivordner verschieben |
| `/email triage` | Letzte 10 Emails als wichtig/unwichtig klassifizieren |
### Sonstiges
| Command | Beschreibung |
|---------|-------------|
| `/status` | Verbindungen prüfen, offene Tasks zählen |
### @Mention
```
@Brain <frage>
@Brain task add <text> [--due YYYY-MM-DD] [--priority hoch]
@Brain task list / done <id> / delete <id>
@Brain email summary / unread / remind / ingest [ordner] / move <ordner>
@Brain remember <text>
@Brain clear
```
PDF-Datei an @Brain anhängen → wird automatisch importiert.
---
## Konfiguration (`config.yml`)
```yaml
# Qdrant-Vektordatenbank
qdrant:
host: "192.168.1.4"
port: "6334"
api_key: "geheimespasswort"
collection: "jacek-brain"
# Embedding-Modell (LocalAI)
embedding:
url: "http://192.168.1.118:8080/v1"
model: "qwen3-embedding-4b"
dimensions: 2560 # muss exakt zum Modell passen
# Chat-Modell (LocalAI)
chat:
url: "http://192.168.1.118:8080/v1"
model: "Qwen3.5-4B-Claude-4.6-Opus-Reasoning-Distilled-GGUF"
# Discord-Bot
discord:
token: "Bot-Token"
guild_id: "" # leer = global (bis 1h Verzögerung); Guild-ID = sofort
allowed_users: # optional: leer = alle erlaubt
- "123456789012345678" # Discord User-ID
# Wissensbasis-Verzeichnis (Markdown-Dateien)
brain_root: "/mnt/c/Users/jacek/AI_Brain"
top_k: 5 # Anzahl der Suchergebnisse pro Anfrage
score_threshold: 0.55 # Minimale Relevanz (0.01.0)
# Task-Speicher
tasks:
store_path: "./tasks.json"
# Daemon-Einstellungen
daemon:
channel_id: "1234567890" # Discord-Channel für proaktive Nachrichten
task_reminder_hour: 8 # Morgen-Briefing Uhrzeit (0-23, Standard: 8)
cleanup_hour: 2 # Archiv-Aufräumen Uhrzeit (0-23, Standard: 2)
ingest_hour: 23 # Email-Ingest Uhrzeit (0-23, Standard: 23)
# RSS-Feeds (optional)
rss_feeds:
- url: "https://example.com/feed.xml"
interval_hours: 24
- url: "https://heise.de/rss/heise.rdf"
interval_hours: 6
# Email — einzelner Account (Legacy)
email:
host: "imap.strato.de"
port: 143
user: "user@example.de"
password: "passwort"
starttls: true # oder tls: true für Port 993
folder: "INBOX"
processed_folder: "" # nach Zusammenfassung verschieben (leer = deaktiviert)
model: "" # eigenes LLM-Modell für Email-Analyse (leer = chat.model)
triage_important_folder: "Wichtig"
triage_unimportant_folder: "Unwichtig"
archive_folders:
- name: "Archiv"
imap_folder: "Archiv"
retention_days: 0 # 0 = dauerhaft behalten
- name: "5Jahre"
imap_folder: "5Jahre"
retention_days: 1825
- name: "2Jahre"
imap_folder: "2Jahre"
retention_days: 730
# Email — mehrere Accounts (hat Vorrang vor email:)
email_accounts:
- name: "Privat"
host: "imap.strato.de"
port: 143
starttls: true
user: "privat@example.de"
password: "passwort"
processed_folder: "Processed"
triage_important_folder: "Wichtig"
triage_unimportant_folder: "Unwichtig"
archive_folders:
- name: "Archiv"
imap_folder: "Archiv"
retention_days: 0
- name: "Arbeit"
host: "imap.firma.de"
port: 993
tls: true
user: "jacek@firma.de"
password: "passwort"
```
> **Wichtig:** Wenn `embedding.model` oder `dimensions` geändert wird, muss die Qdrant-Collection neu erstellt werden (im Dashboard löschen, dann `/memory ingest` erneut ausführen).
---
## Deployment
```bash
# deploy.env anlegen (einmalig)
cp deploy.env.example deploy.env
# Credentials eintragen: DEPLOY_HOST, DEPLOY_USER, DEPLOY_PASS, DEPLOY_DIR, SERVICE_NAME
# Deploy
bash deploy.sh # build + scp + systemctl restart
```
Das Script baut das Linux-Binary, überträgt es per `sshpass`/`scp` und startet den systemd-Service neu.
```bash
# Systemd-Service (einmalig auf dem Server einrichten)
sudo systemctl unmask brain-bot # falls masked
sudo systemctl enable brain-bot
```
---
## Entwicklung
```bash
# Tests ausführen
go test ./...
# Bot lokal starten
go run ./cmd/discord/
# Debug-Logging (LLM-Prompts + Antworten)
DEBUG=1 go run ./cmd/discord/
# Abhängigkeiten aufräumen
go mod tidy
```
### Projektstruktur
```
cmd/
discord/main.go Discord-Bot + Daemon (primärer Einstiegspunkt)
ask/main.go CLI: Fragen stellen
ingest/main.go CLI: Markdown/JSON importieren
mailtest/main.go CLI: IMAP + LLM testen
internal/
config/
config.go Konfigurationsstruktur, Client-Factories
config_test.go Config-Tests
brain/
ask.go RAG-Suche + LLM-Antwort (AskQuery, ChatDirect)
ingest.go Markdown-Import, Chunking, IngestText
ingest_json.go JSON-Import (Bildbeschreibungen)
ingest_email.go IMAP-Ordner → Qdrant
ingest_url.go URL fetchen → Qdrant
ingest_pdf.go PDF-Text → Qdrant
knowledge.go ListSources, DeleteBySource
core_memory.go Kerngedächtnis (core_memory.md)
agents/
agent.go Agent-Interface (Request/Response/HistoryMessage)
actions.go Typsichere Action-Konstanten
memory/agent.go Memory-Agent (store, ingest, url, profile)
research/agent.go Research-Agent (RAG-Suche mit History)
task/
agent.go Task-Agent (add, list, done, delete)
store.go Atomarer JSON-Speicher (tasks.json)
tool/
agent.go Tool-Dispatcher + ResolveArchiveFolder
email/
client.go IMAP-Client (Verbinden, Fetch, Move, Delete)
summary.go Email-Zusammenfassung + LLM-Triage
idle.go IMAP IDLE-Watcher (Echtzeit-Benachrichtigung)
rss/
watcher.go RSS-Feed-Watcher (gofeed + brain.IngestText)
triage/
triage.go RAG-basiertes Triage-Lernen (eigenes Package)
diag/
diag.go Verbindungsdiagnose (/status)
```
---
## Wie es funktioniert
### RAG-Pipeline
1. Nutzer stellt Frage via Discord
2. Frage wird in Vektor umgewandelt (Embedding-Modell)
3. Qdrant liefert die `top_k` ähnlichsten Chunks (Score ≥ `score_threshold`)
4. Core Memory + Chunks + Gesprächshistory werden in LLM-Prompt eingefügt
5. LLM generiert Antwort (Streaming)
### Email-Triage
1. IMAP IDLE meldet neue Email in Echtzeit
2. `triageUnread()` klassifiziert jede Email via LLM (wichtig/unwichtig)
3. Ähnliche frühere Entscheidungen aus Qdrant werden als Few-Shot-Kontext mitgegeben
4. Jede Entscheidung wird in Qdrant gespeichert (Typ `email_triage`) → Modell lernt
5. Wichtige Emails bleiben in INBOX, unwichtige werden in `triage_unimportant_folder` verschoben
### Core Memory
- Datei: `brain_root/core_memory.md`
- Inhalt: Eine Zeile pro Fakt (Markdown-Liste)
- Wird bei **jeder** `AskQuery()`-Anfrage automatisch in den System-Prompt eingebaut
- Kein Neustart nötig (wird bei jedem Call frisch geladen)
### Deterministische IDs
Alle Qdrant-Punkte haben SHA256-basierte IDs (`source:text`). Derselbe Chunk kann beliebig oft importiert werden — Qdrant überschreibt denselben Punkt (Upsert), keine Duplikate.
---
## Externe Dienste
| Dienst | Standard-Adresse | Protokoll |
|--------|-----------------|-----------|
| Qdrant | `192.168.1.4:6334` | gRPC |
| LocalAI (Embedding) | `192.168.1.118:8080` | HTTP/OpenAI |
| LocalAI (Chat) | `192.168.1.118:8080` | HTTP/OpenAI (Streaming) |
| Strato IMAP | `imap.strato.de:143` | STARTTLS |
| Discord | `discord.com` | WebSocket |

View File

@@ -1,13 +1,18 @@
// discord Discord-Bot für my-brain-importer
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention
// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember, /status, /clear und @Mention
package main
import (
"context"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
@@ -21,6 +26,7 @@ import (
"my-brain-importer/internal/agents/task"
"my-brain-importer/internal/agents/tool"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/agents/tool/rss"
"my-brain-importer/internal/brain"
"my-brain-importer/internal/config"
"my-brain-importer/internal/diag"
@@ -93,6 +99,46 @@ var (
Name: "ingest",
Description: "Markdown-Notizen aus brain_root importieren",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "url",
Description: "URL-Inhalt in die Wissensdatenbank importieren",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "url", Description: "Die URL", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "profile",
Description: "Fakt zum Kerngedächtnis hinzufügen",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Fakt", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "profile-show",
Description: "Kerngedächtnis anzeigen",
},
},
},
{
Name: "knowledge",
Description: "Wissensdatenbank verwalten",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "list",
Description: "Gespeicherte Quellen auflisten",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "delete",
Description: "Quelle aus der Wissensdatenbank löschen",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "source", Description: "Quellenname (aus /knowledge list)", Required: true},
},
},
},
},
{
@@ -151,8 +197,55 @@ var (
Name: "remind",
Description: "Termine und Deadlines aus Emails extrahieren",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "ingest",
Description: "Emails aus IMAP-Ordner in Wissensdatenbank importieren",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "ordner", Description: "IMAP-Ordner (Standard: Archiv)", Required: false},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "triage",
Description: "Letzte 10 Emails klassifizieren und in Wichtig/Unwichtig verschieben",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "move",
Description: "Emails in Archivordner verschieben",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "ordner",
Description: "Zielordner",
Required: true,
Choices: []*discordgo.ApplicationCommandOptionChoice{
{Name: "Archiv (dauerhaft)", Value: "Archiv"},
{Name: "5Jahre (~5 Jahre)", Value: "5Jahre"},
{Name: "2Jahre (~2 Jahre)", Value: "2Jahre"},
},
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "alter",
Description: "Alle Emails älter als N Tage verschieben (kein Auswahlmenü)",
Required: false,
MinValue: floatPtr(1),
MaxValue: float64(3650),
},
},
},
},
},
{
Name: "status",
Description: "Bot-Status: Verbindungen prüfen, offene Tasks zählen",
},
{
Name: "clear",
Description: "Gesprächsverlauf für diesen Channel zurücksetzen",
},
}
)
@@ -179,8 +272,16 @@ func addToHistory(channelID, role, content string) {
historyCache[channelID] = msgs
}
// clearHistory löscht den Gesprächsverlauf für einen Channel.
func clearHistory(channelID string) {
historyMu.Lock()
defer historyMu.Unlock()
delete(historyCache, channelID)
}
func main() {
config.LoadConfig()
patchEmailMoveChoices() // Dynamische Archivordner aus Config einsetzen
token := config.Cfg.Discord.Token
if token == "" || token == "dein-discord-bot-token" {
@@ -253,8 +354,55 @@ func registerCommands() {
}
}
// isAllowed prüft ob ein Discord-User den Bot nutzen darf.
// Wenn keine allowed_users konfiguriert sind, ist jeder erlaubt.
func isAllowed(userID string) bool {
if len(config.Cfg.Discord.AllowedUsers) == 0 {
return true
}
for _, id := range config.Cfg.Discord.AllowedUsers {
if id == userID {
return true
}
}
return false
}
// getUserID extrahiert die User-ID aus einer Interaktion.
func getUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
// Berechtigungsprüfung
if !isAllowed(getUserID(i)) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
switch i.Type {
case discordgo.InteractionMessageComponent:
data := i.MessageComponentData()
slog.Info("Komponente", "customID", data.CustomID, "user", getAuthor(i))
if strings.HasPrefix(data.CustomID, "email_move:") {
handleEmailMoveSelect(s, i)
}
return
case discordgo.InteractionApplicationCommand:
// handled below
default:
return
}
@@ -301,6 +449,20 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
case "email":
handleEmailCommand(s, i)
case "knowledge":
handleKnowledgeCommand(s, i)
case "status":
handleStatus(s, i)
case "clear":
clearHistory(i.ChannelID)
reply := "🗑️ Gesprächsverlauf für diesen Channel gelöscht."
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: reply},
})
}
}
@@ -318,6 +480,58 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleAgentResponse(s, i, func() agents.Response {
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
})
case "url":
rawURL := sub.Options[0].StringValue()
handleAgentResponse(s, i, func() agents.Response {
n, err := brain.IngestURL(rawURL)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim URL-Import: %v", err)}
}
return agents.Response{Text: fmt.Sprintf("✅ **%d Chunks** aus URL importiert:\n`%s`", n, rawURL)}
})
case "profile":
text := sub.Options[0].StringValue()
handleAgentResponse(s, i, func() agents.Response {
if err := brain.AppendCoreMemory(text); err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
}
return agents.Response{Text: fmt.Sprintf("🧠 Kerngedächtnis aktualisiert: _%s_", text)}
})
case "profile-show":
handleAgentResponse(s, i, func() agents.Response {
return agents.Response{Text: brain.ShowCoreMemory()}
})
}
}
func handleKnowledgeCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
sub := i.ApplicationCommandData().Options[0]
switch sub.Name {
case "list":
handleAgentResponse(s, i, func() agents.Response {
sources, err := brain.ListSources(0)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)}
}
if len(sources) == 0 {
return agents.Response{Text: "📭 Keine Einträge in der Wissensdatenbank."}
}
var sb strings.Builder
fmt.Fprintf(&sb, "📚 **Quellen in der Wissensdatenbank** (%d):\n```\n", len(sources))
for _, s := range sources {
fmt.Fprintf(&sb, "%s\n", s)
}
sb.WriteString("```")
return agents.Response{Text: sb.String()}
})
case "delete":
source := sub.Options[0].StringValue()
handleAgentResponse(s, i, func() agents.Response {
if err := brain.DeleteBySource(source); err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim Löschen: %v", err)}
}
return agents.Response{Text: fmt.Sprintf("🗑️ Quelle gelöscht: `%s`", source)}
})
}
}
@@ -348,11 +562,239 @@ func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
sub := i.ApplicationCommandData().Options[0]
// Email-Move zeigt ein Select-Menü statt sofort alle zu verschieben
if sub.Name == agents.ActionEmailMove {
handleEmailMoveInit(s, i, sub)
return
}
args := []string{sub.Name}
if sub.Name == agents.ActionEmailIngest && len(sub.Options) > 0 {
args = append(args, sub.Options[0].StringValue())
}
handleAgentResponse(s, i, func() agents.Response {
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub.Name}})
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: args})
})
}
// handleEmailMoveInit zeigt ein Discord Select-Menü mit Emails zur Auswahl oder verschiebt direkt per Alter.
func handleEmailMoveInit(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) {
destName, alterDays := "", 0
for _, opt := range sub.Options {
switch opt.Name {
case "ordner":
destName = opt.StringValue()
case "alter":
alterDays = int(opt.IntValue())
}
}
imapFolder, ok := tool.ResolveArchiveFolder(destName)
if !ok {
msg := fmt.Sprintf("❌ Unbekannter Ordner `%s`.", destName)
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: msg},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
// Bulk-Verschieben aller Emails älter als N Tage — kein Select-Menü nötig
if alterDays > 0 {
n, err := email.MoveOldEmailsAllAccounts(imapFolder, alterDays)
var replyMsg string
if err != nil {
replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err)
} else if n == 0 {
replyMsg = fmt.Sprintf("📭 Keine Emails älter als %d Tage gefunden.", alterDays)
} else {
replyMsg = fmt.Sprintf("✅ %d Email(s) älter als %d Tage nach `%s` verschoben.", n, alterDays, imapFolder)
}
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &replyMsg})
return
}
allAccMsgs, err := email.FetchRecentForSelectAllAccounts(25)
if err != nil {
msg := fmt.Sprintf("❌ Emails konnten nicht abgerufen werden: %v", err)
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
return
}
totalEmails := 0
for _, a := range allAccMsgs {
totalEmails += len(a.Messages)
}
if totalEmails == 0 {
msg := "📭 Keine Emails im Posteingang."
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
return
}
const maxOptions = 25
var components []discordgo.MessageComponent
var headerParts []string
for _, accData := range allAccMsgs {
if len(accData.Messages) == 0 {
continue
}
msgs := accData.Messages
truncated := false
if len(msgs) > maxOptions {
msgs = msgs[:maxOptions]
truncated = true
}
options := make([]discordgo.SelectMenuOption, 0, len(msgs))
for _, m := range msgs {
label := m.Subject
if label == "" {
label = "(kein Betreff)"
}
if len([]rune(label)) > 97 {
label = string([]rune(label)[:97]) + "..."
}
desc := fmt.Sprintf("%s | %s", m.Date, m.From)
if len([]rune(desc)) > 97 {
desc = string([]rune(desc)[:97]) + "..."
}
options = append(options, discordgo.SelectMenuOption{
Label: label,
Value: fmt.Sprintf("%d", m.SeqNum),
Description: desc,
})
}
customID := fmt.Sprintf("email_move:%s:%d", imapFolder, accData.AccIndex)
minVals := 1
maxVals := len(options)
accLabel := accData.Account.Name
if accLabel == "" {
accLabel = accData.Account.User
}
placeholder := "Email(s) auswählen..."
if len(allAccMsgs) > 1 {
placeholder = fmt.Sprintf("Email(s) auswählen %s...", accLabel)
}
components = append(components, discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: customID,
Placeholder: placeholder,
MinValues: &minVals,
MaxValues: maxVals,
Options: options,
},
},
})
note := ""
if truncated {
note = fmt.Sprintf(" *(erste %d von %d)*", maxOptions, len(accData.Messages))
}
if len(allAccMsgs) > 1 {
headerParts = append(headerParts, fmt.Sprintf("**%s**: %d Email(s)%s", accLabel, len(accData.Messages), note))
} else {
headerParts = append(headerParts, fmt.Sprintf("%d Email(s)%s", len(accData.Messages), note))
}
}
msg := fmt.Sprintf("📧 **Emails nach `%s` verschieben**\n%s\nWähle aus welche Emails du verschieben möchtest:", imapFolder, strings.Join(headerParts, "\n"))
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &msg,
Components: &components,
})
}
// handleEmailMoveSelect verarbeitet die Discord Select-Menü Auswahl und verschiebt die gewählten Emails.
func handleEmailMoveSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.MessageComponentData()
// CustomID-Format: email_move:<imapFolder>:<accIndex>
parts := strings.SplitN(data.CustomID, ":", 3)
if len(parts) != 3 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültige CustomID."},
})
return
}
imapFolder := parts[1]
accIndex, err := strconv.Atoi(parts[2])
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültiger Account-Index."},
})
return
}
seqNums := make([]uint32, 0, len(data.Values))
for _, v := range data.Values {
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
continue
}
seqNums = append(seqNums, uint32(n))
}
if len(seqNums) == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: "❌ Keine gültigen Emails ausgewählt."},
})
return
}
// DeferredMessageUpdate: zeigt Ladezustand, editiert dann die ursprüngliche Nachricht
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredMessageUpdate,
})
n, err := email.MoveSpecificUnread(accIndex, seqNums, imapFolder)
var replyMsg string
if err != nil {
replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err)
} else {
replyMsg = fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", n, imapFolder)
}
// Menü entfernen nach Auswahl
emptyComponents := []discordgo.MessageComponent{}
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &replyMsg,
Components: &emptyComponents,
})
}
// handleStatus prüft alle externen Dienste und zeigt offene Task-Anzahl.
func handleStatus(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
results, allOK := diag.RunAll()
// Task-Zähler
store := task.NewStore()
open, err := store.OpenTasks()
taskInfo := ""
if err != nil {
taskInfo = "❌ Tasks: Fehler"
} else {
taskInfo = fmt.Sprintf("📋 Tasks: %d offen", len(open))
}
msg := strings.ReplaceAll(diag.Format(results, allOK), "Start-Diagnose", "Status") + "\n" + taskInfo
s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg})
}
func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
@@ -400,6 +842,23 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
return
}
// Berechtigungsprüfung
if !isAllowed(m.Author.ID) {
s.ChannelMessageSendReply(m.ChannelID, "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.", m.Reference())
return
}
// Datei-Anhänge prüfen (PDF)
for _, att := range m.Attachments {
ext := strings.ToLower(filepath.Ext(att.Filename))
if ext == ".pdf" {
s.ChannelTyping(m.ChannelID)
reply := handlePDFAttachment(att)
s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference())
return
}
}
question := strings.TrimSpace(
strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""),
)
@@ -417,6 +876,37 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference())
}
// handlePDFAttachment lädt eine PDF-Datei herunter, importiert sie und gibt die Antwort zurück.
func handlePDFAttachment(att *discordgo.MessageAttachment) string {
slog.Info("PDF-Attachment erkannt", "datei", att.Filename, "url", att.URL)
// PDF herunterladen
resp, err := http.Get(att.URL) //nolint:noctx
if err != nil {
return fmt.Sprintf("❌ PDF konnte nicht geladen werden: %v", err)
}
defer resp.Body.Close()
// In temporäre Datei schreiben
tmpFile, err := os.CreateTemp("", "brain-pdf-*.pdf")
if err != nil {
return fmt.Sprintf("❌ Temporäre Datei konnte nicht erstellt werden: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return fmt.Sprintf("❌ PDF konnte nicht gespeichert werden: %v", err)
}
tmpFile.Close()
n, err := brain.IngestPDF(tmpFile.Name(), att.Filename)
if err != nil {
return fmt.Sprintf("❌ PDF-Import fehlgeschlagen: %v", err)
}
return fmt.Sprintf("✅ **%d Chunks** aus PDF importiert: `%s`", n, att.Filename)
}
// SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung).
func SendMessage(channelID, text string) error {
if dg == nil {
@@ -438,12 +928,18 @@ func routeMessage(text, author, channelID string) agents.Response {
args := words[1:]
switch cmd {
case "clear":
clearHistory(channelID)
return agents.Response{Text: "🗑️ Gesprächsverlauf gelöscht."}
case "email":
sub := "summary"
emailArgs := []string{}
if len(args) > 0 {
sub = strings.ToLower(args[0])
emailArgs = args[1:] // Restargumente (z.B. Ordnername für "move")
}
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub}})
return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: append([]string{sub}, emailArgs...)})
case "task":
action := "list"
@@ -507,28 +1003,24 @@ func sendWelcomeMessage() {
**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
/ask <frage> Wissensdatenbank abfragen
/research <frage> Alias für /ask
/asknobrain <frage> Direkt ans LLM (kein RAG)
/memory store <text> Text speichern
/memory ingest Markdown-Notizen neu einlesen
/memory url <url> URL-Inhalt importieren
/memory profile <text> Fakt zum Kerngedächtnis hinzufügen
/memory profile-show Kerngedächtnis anzeigen
/knowledge list Gespeicherte Quellen auflisten
/knowledge delete <source> Quelle löschen
/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
/task list / done / delete
/email summary / unread / remind / ingest / move / triage
/status Bot-Status
/clear Gesprächsverlauf zurücksetzen
` + "```" + `
**@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`
**@Mention:** PDF-Anhang schicken → automatisch importiert
⚙️ Daemon: IMAP IDLE + Morgen-Briefing + RSS-Feeds`
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err)
@@ -545,7 +1037,73 @@ func getAuthor(i *discordgo.InteractionCreate) string {
return "unknown"
}
// patchEmailMoveChoices aktualisiert die /email move Choices in der commands-Liste nach dem Laden der Config.
// Wird in main() nach config.LoadConfig() aufgerufen.
func patchEmailMoveChoices() {
choices := buildMoveChoices()
for _, cmd := range commands {
if cmd.Name != "email" {
continue
}
for _, opt := range cmd.Options {
if opt.Name != "move" {
continue
}
for _, subOpt := range opt.Options {
if subOpt.Name == "ordner" {
subOpt.Choices = choices
return
}
}
}
}
}
// buildMoveChoices erstellt Discord-Choices für /email move aus der konfigurierten archive_folders.
// Fallback: statische Liste (2Jahre/5Jahre/Archiv) wenn keine archive_folders konfiguriert.
func buildMoveChoices() []*discordgo.ApplicationCommandOptionChoice {
seen := map[string]bool{}
var choices []*discordgo.ApplicationCommandOptionChoice
for _, acc := range config.AllEmailAccounts() {
for _, af := range acc.ArchiveFolders {
key := strings.ToLower(af.Name)
if seen[key] {
continue
}
seen[key] = true
label := af.Name
if af.RetentionDays > 0 {
label = fmt.Sprintf("%s (%d Tage)", af.Name, af.RetentionDays)
}
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{
Name: label,
Value: af.Name,
})
if len(choices) == 25 { // Discord-Limit
slog.Warn("Mehr als 25 Archivordner konfiguriert, Liste wird gekürzt")
return choices
}
}
}
if len(choices) == 0 {
// Legacy-Fallback
choices = []*discordgo.ApplicationCommandOptionChoice{
{Name: "Archiv (dauerhaft)", Value: "Archiv"},
{Name: "5Jahre (~5 Jahre)", Value: "5Jahre"},
{Name: "2Jahre (~2 Jahre)", Value: "2Jahre"},
}
}
return choices
}
// floatPtr gibt einen Pointer auf einen float64-Wert zurück (für MinValue in Discord-Options).
func floatPtr(v float64) *float64 { return &v }
// startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen.
// Für Email-Benachrichtigungen wird IMAP IDLE genutzt (Echtzeit).
// Alternativ, wenn kein Email-Account konfiguriert ist, läuft nur der Morgen-Briefing-Timer.
func startDaemon() {
channelID := config.Cfg.Daemon.ChannelID
if channelID == "" {
@@ -553,53 +1111,121 @@ func startDaemon() {
return
}
emailInterval := time.Duration(config.Cfg.Daemon.EmailIntervalMin) * time.Minute
if emailInterval == 0 {
emailInterval = 30 * time.Minute
}
reminderHour := config.Cfg.Daemon.TaskReminderHour
if reminderHour == 0 {
reminderHour = 8
}
log.Printf("⚙️ Daemon aktiv: Email-Check alle %v, Task-Reminder täglich um %02d:00", emailInterval, reminderHour)
// IMAP IDLE für jeden konfigurierten Account starten
ctx, cancel := context.WithCancel(context.Background())
accounts := config.AllEmailAccounts()
if len(accounts) > 0 {
log.Printf("⚙️ Daemon aktiv: IMAP IDLE für %d Account(s), Task-Reminder täglich um %02d:00", len(accounts), reminderHour)
for _, acc := range accounts {
watcher := email.NewIdleWatcher(acc, func(accountName, summary string) {
slog.Info("IDLE: Neue Emails, sende Zusammenfassung", "account", accountName)
dg.ChannelMessageSend(channelID, fmt.Sprintf("📧 **Neue Emails (%s):**\n\n%s", accountName, summary))
})
go watcher.Run(ctx)
}
} else {
log.Printf("⚙️ Daemon aktiv (kein Email-Account): Task-Reminder täglich um %02d:00", reminderHour)
}
emailTicker := time.NewTicker(emailInterval)
defer emailTicker.Stop()
// RSS-Watcher starten (wenn Feeds konfiguriert)
if len(config.Cfg.RSSFeeds) > 0 {
log.Printf("⚙️ RSS-Watcher aktiv: %d Feed(s)", len(config.Cfg.RSSFeeds))
rssWatcher := &rss.Watcher{
OnResults: func(summary string) {
slog.Info("RSS: Feeds importiert")
dg.ChannelMessageSend(channelID, "🗞️ **RSS-Feeds importiert:**\n"+summary)
},
}
go rssWatcher.Run(ctx)
}
cleanupHour := config.Cfg.Daemon.CleanupHour
if cleanupHour == 0 {
cleanupHour = 2
}
ingestHour := config.Cfg.Daemon.IngestHour
if ingestHour == 0 {
ingestHour = 23
}
briefingTimer := scheduleDaily(reminderHour, 0)
defer briefingTimer.Stop()
cleanupTimer := scheduleDaily(cleanupHour, 0)
defer cleanupTimer.Stop()
ingestTimer := scheduleDaily(ingestHour, 0)
defer ingestTimer.Stop()
for {
select {
case <-daemonStop:
slog.Info("Daemon gestoppt")
cancel()
return
case <-emailTicker.C:
slog.Info("Daemon: Email-Check gestartet")
notify, err := email.SummarizeUnread()
if err != nil {
slog.Error("Daemon Email-Fehler", "fehler", err)
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Email-Check fehlgeschlagen: %v", err))
continue
}
if notify != "📭 Keine ungelesenen Emails." {
slog.Info("Daemon: Neue Emails gefunden, sende Zusammenfassung")
dg.ChannelMessageSend(channelID, "📧 **Neue Emails:**\n\n"+notify)
} else {
slog.Info("Daemon: Keine neuen Emails")
}
case <-briefingTimer.C:
slog.Info("Daemon: Morgen-Briefing gestartet")
dailyBriefing(channelID)
briefingTimer.Stop()
briefingTimer = scheduleDaily(reminderHour, 0)
case <-cleanupTimer.C:
slog.Info("Daemon: Archiv-Aufräumen gestartet")
go func() {
summary, err := email.CleanupArchiveFolders()
if err != nil {
slog.Error("Daemon: Archiv-Aufräumen Fehler", "fehler", err)
} else {
slog.Info("Daemon: Archiv-Aufräumen abgeschlossen", "ergebnis", summary)
}
}()
cleanupTimer.Stop()
cleanupTimer = scheduleDaily(cleanupHour, 0)
case <-ingestTimer.C:
slog.Info("Daemon: Nächtlicher Email-Ingest gestartet")
go nightlyIngest(channelID)
ingestTimer.Stop()
ingestTimer = scheduleDaily(ingestHour, 0)
}
}
}
// nightlyIngest importiert Emails aus allen Archiv-Ordnern in die Wissensdatenbank.
func nightlyIngest(channelID string) {
accounts := config.AllEmailAccounts()
total := 0
var errs []string
for _, acc := range accounts {
for _, af := range acc.ArchiveFolders {
n, err := brain.IngestEmailFolder(acc, af.IMAPFolder, 0)
if err != nil {
slog.Error("Nacht-Ingest Fehler", "account", acc.Name, "folder", af.IMAPFolder, "fehler", err)
errs = append(errs, fmt.Sprintf("%s/%s: %v", acc.Name, af.IMAPFolder, err))
continue
}
slog.Info("Nacht-Ingest abgeschlossen", "account", acc.Name, "folder", af.IMAPFolder, "ingested", n)
total += n
}
}
if channelID == "" {
return
}
if len(errs) > 0 {
dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Nacht-Ingest: %d Emails importiert, %d Fehler:\n%s",
total, len(errs), strings.Join(errs, "\n")))
} else if total > 0 {
dg.ChannelMessageSend(channelID, fmt.Sprintf("🗄️ Nacht-Ingest: %d Emails aus Archiv-Ordnern importiert.", total))
}
}
// scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert.
func scheduleDaily(hour, minute int) *time.Timer {
now := time.Now()
@@ -623,6 +1249,7 @@ func dailyBriefing(channelID string) {
open, err := store.OpenTasks()
if err != nil {
slog.Error("Daemon Briefing Task-Fehler", "fehler", err)
open = nil // explizit nil, len(open)==0 → kein Task-Abschnitt
} else if len(open) > 0 {
fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open))
for _, t := range open {

View File

@@ -1,60 +1,234 @@
# Architektur
```
AI_Brain/
*.md Dateien
bin/ingest Embeddings via LocalAI
Qdrant (NAS) ◄──── bin/ask ──► LM Studio (Chat)
```
- **Embeddings**: LocalAI unter `embedding.url` (Modell konfigurierbar)
- **Vektordatenbank**: Qdrant auf dem NAS
- **Chat-Completion**: LocalAI unter `chat.url` (Modell konfigurierbar)
## Projektstruktur
```
AI-Agent/
cmd/
ingest/main.go Entry Point für ingest-Binary
ask/main.go Entry Point für ask-Binary
internal/
config/config.go Config-Struct, Clients, Verbindungen
brain/
ingest.go Markdown-Import, Chunking
ingest_json.go JSON-Import (Bildbeschreibungen)
ask.go Suche + LLM-Antwort
bin/ Kompilierte Binaries (von build.sh erzeugt)
config.yml Alle Einstellungen
build.sh Baut beide Binaries
```
## Konfiguration
Alle Einstellungen in `config.yml` (muss im Arbeitsverzeichnis liegen):
```yaml
qdrant:
host: "192.168.1.4"
port: "6334"
api_key: "..."
collection: "jacek-brain"
embedding:
url: "http://192.168.1.118:8080/v1"
model: "qwen3-embedding-4b"
dimensions: 2560 # muss zum Modell passen
chat:
url: "http://192.168.1.118:8080/v1"
model: "qwen3.5-4b-claude-4.6-opus-reasoning-distilled"
brain_root: "/mnt/c/Users/jacek/AI_Brain"
top_k: 3
```
> **Wichtig:** Wenn du `embedding.model` oder `dimensions` änderst, muss die Qdrant-Collection neu erstellt werden (im Dashboard löschen, dann `ingest` erneut ausführen).
# Architektur
## Übersicht
```
Discord (primäres Interface)
Slash-Commands + @Mention + PDF-Anhänge
cmd/discord/main.go
├── Research-Agent → brain.AskQuery() + Konversationsverlauf pro Channel
├── Memory-Agent → brain.RunIngest(), IngestChatMessage(), IngestURL(), CoreMemory
├── Task-Agent → tasks.json (atomares JSON, DueDate + Priority)
├── Tool-Agent → Dispatcher für Email-Aktionen
└── Daemon-Goroutinen:
├── IMAP IDLE (pro Account) → Echtzeit-Triage + Discord-Benachrichtigung
├── RSS-Watcher → Artikel-Import in Qdrant
├── Morgen-Briefing (08:00) → Tasks + Emails kombiniert
├── Archiv-Cleanup (02:00) → CleanupArchiveFolders() nach retention_days
└── Nacht-Ingest (23:00) → brain.IngestEmailFolder() für alle Archive
Qdrant (gRPC, 192.168.1.4:6334) LocalAI (HTTP, 192.168.1.118:8080)
Vektordatenbank Embedding-Modell + Chat-Modell
```
---
## Packages
### `cmd/discord/`
Primärer Einstiegspunkt. Registriert Discord Slash-Commands, verarbeitet Interaktionen und @Mentions, startet den Daemon.
**Wichtige Funktionen:**
- `main()` — Config laden, Discord verbinden, Commands registrieren, Daemon starten
- `onInteraction()` — Slash-Command-Handler mit Berechtigungsprüfung
- `onMessage()`@Mention-Handler inkl. PDF-Anhang-Erkennung
- `routeMessage()` — Leitet @Mention-Text an passenden Agenten weiter
- `startDaemon()` — Startet IMAP IDLE, RSS-Watcher, tägliche Timer
- `dailyBriefing()` — Morgen-Briefing (Tasks + Emails)
- `nightlyIngest()` — Archiv-Ordner in Qdrant importieren
- `patchEmailMoveChoices()` — Discord-Choices dynamisch aus Config befüllen
- `isAllowed(userID)` — User-Berechtigungsprüfung
### `internal/config/`
Konfigurationsstruktur (`Config`), Client-Factories und `AllEmailAccounts()`.
```go
type Config struct {
Qdrant, Embedding, Chat // Externe Dienste
Discord // Token, GuildID, AllowedUsers
Email / EmailAccounts // IMAP (Legacy/Multi-Account)
Tasks // JSON-Pfad
Daemon // Timer-Uhrzeiten, Channel-ID
BrainRoot, TopK, ScoreThreshold
RSSFeeds // RSS-Feed-URLs + Intervalle
}
```
- `LoadConfig()` — liest `config.yml`, validiert Pflichtfelder
- `AllEmailAccounts()` — gibt alle Accounts zurück (Multi-Account-Vorrang über Legacy)
- `NewQdrantConn()`, `NewEmbeddingClient()`, `NewChatClient()` — Client-Factories
### `internal/brain/`
Core RAG-Logik. Alle Funktionen sind zustandslos (keine globalen Verbindungen).
| Datei | Inhalt |
|-------|--------|
| `ask.go` | `AskQuery()` (Suche + LLM), `ChatDirect()`, `searchKnowledge()` |
| `ingest.go` | `RunIngest()` (Markdown), `IngestChatMessage()`, `IngestText()`, Chunking |
| `ingest_json.go` | JSON-Import (Bildbeschreibungen) |
| `ingest_email.go` | `IngestEmailFolder()` — IMAP-Ordner → Qdrant |
| `ingest_url.go` | `IngestURL()` — HTTP-Fetch + HTML-Text-Extraktion → Qdrant |
| `ingest_pdf.go` | `IngestPDF()` — PDF-Text-Extraktion → Qdrant |
| `knowledge.go` | `ListSources()` (Scroll), `DeleteBySource()` (Filter-Delete) |
| `core_memory.go` | `LoadCoreMemory()`, `AppendCoreMemory()``brain_root/core_memory.md` |
**ID-Schema:** `SHA256(source + ":" + text)[:16]` als Hex — deterministische Upserts, keine Duplikate.
**Chunking-Strategie:**
1. Text nach Markdown-Überschriften (`#`, `##`, `###`) aufteilen
2. Abschnitte > 800 Zeichen nach Paragraphen (`\n\n`) aufteilen
3. Minimum-Länge 20 Zeichen
**Core Memory in AskQuery:**
```
System-Prompt = Basis-Prompt
+ "\n\n## Fakten über den Nutzer:\n" + core_memory.md (wenn nicht leer)
```
### `internal/agents/`
**`agent.go`** — Gemeinsame Interfaces:
```go
type Agent interface { Handle(Request) Response }
type Request struct { Action, Args, Author, Source, History }
type Response struct { Text, RawAnswer string; Error error }
type HistoryMessage struct { Role, Content string }
```
**`actions.go`** — Alle Action-Konstanten (typsicher, keine Magic Strings).
**`memory/agent.go`** — Delegiert an `brain.*`:
- `store``brain.IngestChatMessage()`
- `ingest``brain.RunIngest()`
**`research/agent.go`** — Ruft `brain.AskQuery()` auf, formatiert Antwort mit Quellenangaben.
**`task/agent.go`** + **`task/store.go`** — Task-CRUD über atomares `tasks.json`:
- Felder: `ID` (UUID), `Text`, `Done bool`, `DueDate *time.Time`, `Priority string`
- Atomares Schreiben: temp-Datei → rename (kein Datenverlust bei Absturz)
### `internal/agents/tool/`
**`agent.go`** — Dispatcht Email-Actions an `email`-Package-Funktionen. `ResolveArchiveFolder()` für case-insensitive Ordnersuche.
**`email/client.go`** — IMAP-Client-Wrapper:
- `ConnectAccount()` — Verbindung (STARTTLS oder implizites TLS)
- `FetchUnread()`, `FetchWithBody()`, `FetchRecentForSelect()`
- `MoveMessages()`, `MoveOldEmails()`, `DeleteByAge()`
**`email/summary.go`** — Email-Zusammenfassung + Triage:
- `SummarizeUnread()` — Alle Accounts, LLM-Zusammenfassung
- `triageUnread()` — LLM-Klassifikation + Qdrant-Lernen via `triage` Package
- `CleanupArchiveFolders()` — Alte Emails löschen nach `retention_days`
**`email/idle.go`** — IMAP IDLE-Watcher:
- `IdleWatcher` — pro Account, race-sicher mit `atomic.Uint32`
- Automatischer Reconnect nach 60s bei Fehler
- Callback bei neuen Emails → `triageUnread()` + Discord-Nachricht
**`rss/watcher.go`** — RSS-Feed-Watcher:
- `IngestFeed(url)` — gofeed Parser → Artikel als Text → `brain.IngestText()`
- `Watcher.Run(ctx)` — Goroutine, pollt alle Feeds im minimalen Intervall
- `IngestAllFeeds()` / `FormatResults()` — Batch-Import + Discord-Formatting
### `internal/triage/`
Eigenes Package um Import-Zyklen zu vermeiden (`brain``email``triage`).
- `StoreTriage()` — Triage-Entscheidung in Qdrant speichern (Typ `email_triage`)
- `SearchSimilar()` — Ähnliche frühere Entscheidungen finden (Score ≥ 0.7) als Few-Shot-Kontext
### `internal/diag/`
- `RunAll()` — Prüft Qdrant, LocalAI (Embedding + Chat), IMAP-Verbindungen
- `Format()` / `Log()` — Ausgabe für Discord und Konsole
---
## Datenflüsse
### Slash-Command: `/ask <frage>`
```
Discord → onInteraction → researchAgent.Handle(ActionQuery)
→ research/agent.go → brain.AskQuery(frage, history)
→ searchKnowledge() → Qdrant Search
→ LoadCoreMemory()
→ LLM Stream (LocalAI)
→ Discord Edit (Deferred Response)
```
### @Bot + PDF-Anhang
```
Discord → onMessage → Attachment .pdf erkannt
→ http.Get(att.URL) → temp-Datei
→ brain.IngestPDF(tmpFile, att.Filename)
→ ledongthuc/pdf → Text extrahieren
→ ingestChunks() → Qdrant Upsert
→ Discord Reply
```
### IMAP IDLE → Neue Email
```
email.IdleWatcher.Run()
→ IMAP IDLE Command (Server-Push)
→ triageUnread()
→ triage.SearchSimilar() (Few-Shot aus Qdrant)
→ LLM: wichtig / unwichtig?
→ triage.StoreTriage() (Entscheidung → Qdrant)
→ email.MoveMessages() (IMAP Move)
→ SummarizeUnreadAccount() (LLM-Zusammenfassung)
→ Discord ChannelMessageSend
```
### RSS-Watcher
```
rss.Watcher.Run(ctx)
→ gofeed.Parser.ParseURL(feedURL)
→ buildArticleText(item) (Titel + Datum + URL + Beschreibung)
→ brain.IngestText()
→ splitLongSection()
→ ingestChunks() → Qdrant Upsert
→ Discord ChannelMessageSend (Zusammenfassung)
```
---
## Konfigurationsvalidierung
`validateConfig()` prüft beim Start:
- `qdrant.host`, `qdrant.port`
- `embedding.url`, `embedding.model`
- `chat.url`, `chat.model`
Fehlt eines dieser Felder → fataler Fehler, Bot startet nicht.
Discord-Token wird separat in `main()` geprüft.
---
## Deployment-Architektur
```
Entwickler-PC (WSL2)
│ bash deploy.sh
│ sshpass + scp
Home-Server (192.168.1.118)
├── systemd: brain-bot.service
│ ExecStart: /home/christoph/brain-bot/brain-bot
│ WorkingDirectory: /home/christoph/brain-bot/
│ (config.yml liegt hier)
├── LocalAI (Port 8080) — Embeddings + Chat
└── Qdrant (auf 192.168.1.4, Port 6334) — Vektordatenbank
```
---
## Bekannte Grenzen
| Grenze | Details |
|--------|---------|
| LLM-Kontextfenster | `MaxTokens: 600` — lange Email-Listen werden abgeschnitten |
| LLM-Latenz | 560s je nach Modell + NAS-Last |
| IMAP-Encoding | Strato: `windows-1252` Betreffzeilen werden nicht dekodiert |
| Streaming-Timeout | Kein expliziter LLM-Timeout — Discord-Interaktion läuft nach 15min ab |
| PDF-Extraktion | Nur Text-PDFs; gescannte PDFs (nur Bilder) liefern keinen Text |
| Discord Message-Limit | 2000 Zeichen — lange Antworten werden abgeschnitten |

10
go.mod
View File

@@ -1,6 +1,6 @@
module my-brain-importer
go 1.22.2
go 1.24.1
require (
github.com/bwmarrin/discordgo v0.29.0
@@ -12,9 +12,17 @@ require (
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // indirect
github.com/mmcdole/gofeed v1.3.0 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect

26
go.sum
View File

@@ -1,5 +1,11 @@
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
@@ -14,14 +20,31 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdrant/go-client v1.12.0 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T4M=
github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw=
github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
@@ -44,6 +67,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
@@ -53,6 +77,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -64,6 +89,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

View File

@@ -15,9 +15,22 @@ const (
ActionDone = "done"
ActionDelete = "delete"
// Memory
ActionIngestURL = "url"
ActionIngestPDF = "pdf"
ActionProfile = "profile"
ActionProfileShow = "profile-show"
// Knowledge
ActionKnowledgeList = "list"
ActionKnowledgeDelete = "delete"
// Tool/Email
ActionEmail = "email"
ActionEmail = "email"
ActionEmailSummary = "summary"
ActionEmailUnread = "unread"
ActionEmailRemind = "remind"
ActionEmailIngest = "ingest"
ActionEmailMove = "move"
ActionEmailTriage = "triage"
)

View File

@@ -3,9 +3,12 @@ package tool
import (
"fmt"
"strings"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/brain"
"my-brain-importer/internal/config"
)
// Agent verteilt Tool-Anfragen an spezialisierte Sub-Agenten.
@@ -41,8 +44,14 @@ func (a *Agent) handleEmail(req agents.Request) agents.Response {
result, err = email.SummarizeUnread()
case agents.ActionEmailRemind:
result, err = email.ExtractReminders()
case agents.ActionEmailIngest:
return a.handleEmailIngest(req)
case agents.ActionEmailMove:
return a.handleEmailMove(req)
case agents.ActionEmailTriage:
return a.handleEmailTriage()
default:
return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind", subAction)}
return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind, ingest, move, triage", subAction)}
}
if err != nil {
@@ -50,3 +59,144 @@ func (a *Agent) handleEmail(req agents.Request) agents.Response {
}
return agents.Response{Text: "📧 **Email-Analyse:**\n\n" + result}
}
// handleEmailIngest importiert Emails aus einem IMAP-Ordner in Qdrant.
// Args[1] = Ordnername (Standard: "Archiv")
func (a *Agent) handleEmailIngest(req agents.Request) agents.Response {
folder := "Archiv"
if len(req.Args) > 1 && req.Args[1] != "" {
folder = req.Args[1]
}
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return agents.Response{Text: "❌ Kein Email-Account konfiguriert."}
}
total := 0
var errs []string
for _, acc := range accounts {
n, err := brain.IngestEmailFolder(acc, folder, 500)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", acc.Name, err))
continue
}
total += n
}
if len(errs) > 0 && total == 0 {
return agents.Response{Text: fmt.Sprintf("❌ Email-Ingest fehlgeschlagen:\n%s", joinLines(errs))}
}
msg := fmt.Sprintf("✅ **Email-Ingest abgeschlossen:** %d Emails aus `%s` in die Wissensdatenbank importiert.", total, folder)
if len(errs) > 0 {
msg += "\n⚠ Fehler bei einigen Accounts:\n" + joinLines(errs)
}
return agents.Response{Text: msg}
}
// handleEmailMove verschiebt alle ungelesenen Emails in einen konfigurierten Archivordner.
// Args[1] = Zielordner-Name (aus archive_folders in config oder Legacy: 2Jahre/5Jahre/Archiv)
func (a *Agent) handleEmailMove(req agents.Request) agents.Response {
if len(req.Args) < 2 || req.Args[1] == "" {
return agents.Response{Text: "❌ Zielordner fehlt. " + buildMoveFoldersHint()}
}
dest := req.Args[1]
imapFolder, ok := resolveArchiveFolder(dest)
if !ok {
return agents.Response{Text: fmt.Sprintf("❌ Unbekannter Ordner `%s`. %s", dest, buildMoveFoldersHint())}
}
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return agents.Response{Text: "❌ Kein Email-Account konfiguriert."}
}
total := 0
var errs []string
for _, acc := range accounts {
n, err := email.MoveUnread(acc, imapFolder)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", acc.Name, err))
continue
}
total += n
}
if len(errs) > 0 && total == 0 {
return agents.Response{Text: fmt.Sprintf("❌ Verschieben fehlgeschlagen:\n%s", joinLines(errs))}
}
if total == 0 {
return agents.Response{Text: fmt.Sprintf("📭 Keine ungelesenen Emails zum Verschieben nach `%s`.", imapFolder)}
}
msg := fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", total, imapFolder)
if len(errs) > 0 {
msg += "\n⚠ Fehler:\n" + joinLines(errs)
}
return agents.Response{Text: msg}
}
// handleEmailTriage klassifiziert die letzten 10 Emails aller Accounts und verschiebt sie.
func (a *Agent) handleEmailTriage() agents.Response {
result, err := email.TriageRecentAllAccounts(10)
if err != nil {
return agents.Response{Error: err, Text: fmt.Sprintf("❌ Triage fehlgeschlagen: %v", err)}
}
return agents.Response{Text: "🗂️ **Email-Triage (letzte 10 Emails):**\n\n" + result}
}
// ResolveArchiveFolder ist die exportierte Version von resolveArchiveFolder für den Discord-Layer.
func ResolveArchiveFolder(name string) (imapFolder string, ok bool) {
return resolveArchiveFolder(name)
}
// resolveArchiveFolder sucht den IMAP-Ordnernamen für einen Anzeigenamen aus der Config.
// Fallback: Legacy-Hardcoding für 2Jahre/5Jahre/Archiv wenn keine archive_folders konfiguriert.
func resolveArchiveFolder(name string) (imapFolder string, ok bool) {
for _, acc := range config.AllEmailAccounts() {
for _, af := range acc.ArchiveFolders {
if strings.EqualFold(af.Name, name) || strings.EqualFold(af.IMAPFolder, name) {
return af.IMAPFolder, true
}
}
}
// Legacy-Fallback für Konfigurationen ohne archive_folders
legacy := map[string]string{
"2jahre": "2Jahre",
"5jahre": "5Jahre",
"archiv": "Archiv",
}
if canonical, found := legacy[strings.ToLower(name)]; found {
return canonical, true
}
return "", false
}
// buildMoveFoldersHint gibt eine Hinweis-Nachricht mit verfügbaren Archivordnern zurück.
func buildMoveFoldersHint() string {
seen := map[string]bool{}
var names []string
for _, acc := range config.AllEmailAccounts() {
for _, af := range acc.ArchiveFolders {
key := strings.ToLower(af.Name)
if !seen[key] {
seen[key] = true
names = append(names, fmt.Sprintf("`%s`", af.Name))
}
}
}
if len(names) == 0 {
return "Verfügbar: `2Jahre`, `5Jahre`, `Archiv`"
}
return fmt.Sprintf("Verfügbar: %s", strings.Join(names, ", "))
}
func joinLines(lines []string) string {
result := ""
for _, l := range lines {
result += "• " + l + "\n"
}
return result
}

View File

@@ -3,7 +3,12 @@ package email
import (
"crypto/tls"
"encoding/base64"
"fmt"
"log/slog"
"mime/quotedprintable"
"strings"
"time"
imap "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
@@ -18,15 +23,43 @@ type Message struct {
Date string
}
// Client wraps die IMAP-Verbindung.
type Client struct {
c *imapclient.Client
// SelectMessage koppelt eine Message mit ihrer IMAP-Sequenznummer für UI-Zwecke.
type SelectMessage struct {
Message
SeqNum uint32
Unread bool // true = \Seen flag nicht gesetzt
}
// Connect öffnet eine IMAP-Verbindung.
// MessageWithBody repräsentiert eine Email mit Text-Inhalt (für Datenbankimport).
type MessageWithBody struct {
Message
Body string
}
// Client wraps die IMAP-Verbindung.
type Client struct {
c *imapclient.Client
folder string // INBOX-Ordner (leer = "INBOX")
}
// Connect öffnet eine IMAP-Verbindung mit dem Legacy-Email-Block aus der Config.
func Connect() (*Client, error) {
cfg := config.Cfg.Email
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
acc := config.EmailAccount{
Host: cfg.Host,
Port: cfg.Port,
User: cfg.User,
Password: cfg.Password,
TLS: cfg.TLS,
StartTLS: cfg.StartTLS,
Folder: cfg.Folder,
}
return ConnectAccount(acc)
}
// ConnectAccount öffnet eine IMAP-Verbindung für einen bestimmten EmailAccount.
func ConnectAccount(acc config.EmailAccount) (*Client, error) {
addr := fmt.Sprintf("%s:%d", acc.Host, acc.Port)
var (
c *imapclient.Client
@@ -34,11 +67,11 @@ func Connect() (*Client, error) {
)
switch {
case cfg.TLS:
tlsCfg := &tls.Config{ServerName: cfg.Host}
case acc.TLS:
tlsCfg := &tls.Config{ServerName: acc.Host}
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
case cfg.StartTLS:
tlsCfg := &tls.Config{ServerName: cfg.Host}
case acc.StartTLS:
tlsCfg := &tls.Config{ServerName: acc.Host}
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
default:
c, err = imapclient.DialInsecure(addr, nil)
@@ -47,12 +80,12 @@ func Connect() (*Client, error) {
return nil, fmt.Errorf("IMAP verbinden: %w", err)
}
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
if err := c.Login(acc.User, acc.Password).Wait(); err != nil {
c.Close()
return nil, fmt.Errorf("IMAP login: %w", err)
}
return &Client{c: c}, nil
return &Client{c: c, folder: acc.Folder}, nil
}
// Close schließt die Verbindung.
@@ -61,9 +94,28 @@ func (cl *Client) Close() {
cl.c.Close()
}
// EnsureFolder legt einen IMAP-Ordner an falls er nicht existiert.
// Strato-kompatibel: ignoriert alle "already exists"-Varianten.
func (cl *Client) EnsureFolder(folder string) error {
err := cl.c.Create(folder, nil).Wait()
if err == nil {
slog.Info("IMAP: Ordner angelegt", "ordner", folder)
return nil
}
errLower := strings.ToLower(err.Error())
if strings.Contains(errLower, "already exists") ||
strings.Contains(errLower, "alreadyexists") ||
strings.Contains(errLower, "mailbox exists") ||
strings.Contains(errLower, "exists") {
return nil // Ordner existiert bereits — kein Fehler
}
slog.Error("IMAP: Ordner anlegen fehlgeschlagen", "ordner", folder, "fehler", err)
return fmt.Errorf("IMAP create folder %s: %w", folder, err)
}
// FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body).
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
folder := config.Cfg.Email.Folder
folder := cl.folder
if folder == "" {
folder = "INBOX"
}
@@ -94,7 +146,7 @@ func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
func (cl *Client) FetchUnread() ([]Message, error) {
folder := config.Cfg.Email.Folder
folder := cl.folder
if folder == "" {
folder = "INBOX"
}
@@ -129,7 +181,7 @@ func (cl *Client) FetchUnread() ([]Message, error) {
// 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
folder := cl.folder
if folder == "" {
folder = "INBOX"
}
@@ -172,6 +224,328 @@ func (cl *Client) MoveMessages(seqNums []uint32, destFolder string) error {
return nil
}
// FetchUnreadForSelect gibt ungelesene Emails mit ihren Sequenznummern zurück.
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
func (cl *Client) FetchUnreadForSelect() ([]SelectMessage, error) {
folder := cl.folder
if folder == "" {
folder = "INBOX"
}
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
return 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, fmt.Errorf("IMAP search: %w", err)
}
seqNums := searchData.AllSeqNums()
if len(seqNums) == 0 {
return nil, nil
}
var seqSet imap.SeqSet
seqSet.AddNum(seqNums...)
rawMsgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
if err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
seqToMsg := make(map[uint32]*imapclient.FetchMessageBuffer, len(rawMsgs))
for _, m := range rawMsgs {
if m.Envelope != nil {
seqToMsg[m.SeqNum] = m
}
}
result := make([]SelectMessage, 0, len(seqNums))
for _, sn := range seqNums {
m, ok := seqToMsg[sn]
if !ok {
continue
}
result = append(result, SelectMessage{
Message: parseMessage(m),
SeqNum: sn,
Unread: true,
})
}
return result, nil
}
// FetchRecentForSelect gibt die letzten n Emails mit Sequenznummern und Unread-Status zurück.
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
func (cl *Client) FetchRecentForSelect(n uint32) ([]SelectMessage, error) {
folder := cl.folder
if folder == "" {
folder = "INBOX"
}
selectData, err := cl.c.Select(folder, nil).Wait()
if err != nil {
return nil, fmt.Errorf("IMAP select: %w", err)
}
if selectData.NumMessages == 0 {
return nil, nil
}
start := uint32(1)
if selectData.NumMessages > n {
start = selectData.NumMessages - n + 1
}
var seqSet imap.SeqSet
seqSet.AddRange(start, selectData.NumMessages)
rawMsgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true, Flags: true}).Collect()
if err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
result := make([]SelectMessage, 0, len(rawMsgs))
for _, m := range rawMsgs {
if m.Envelope == nil {
continue
}
unread := true
for _, f := range m.Flags {
if f == imap.FlagSeen {
unread = false
break
}
}
result = append(result, SelectMessage{
Message: parseMessage(m),
SeqNum: m.SeqNum,
Unread: unread,
})
}
return result, nil
}
// MoveOldMessages verschiebt alle Emails im Ordner, die älter als olderThanDays Tage sind, nach destFolder.
// Gibt die Anzahl verschobener Nachrichten zurück. olderThanDays <= 0 ist ein No-op.
func (cl *Client) MoveOldMessages(folder, destFolder string, olderThanDays int) (int, error) {
if olderThanDays <= 0 {
return 0, nil
}
if folder == "" {
folder = "INBOX"
}
cutoff := time.Now().AddDate(0, 0, -olderThanDays).Truncate(24 * time.Hour)
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
return 0, fmt.Errorf("IMAP select %s: %w", folder, err)
}
searchData, err := cl.c.Search(&imap.SearchCriteria{Before: cutoff}, nil).Wait()
if err != nil {
return 0, fmt.Errorf("IMAP search: %w", err)
}
seqNums := searchData.AllSeqNums()
if len(seqNums) == 0 {
return 0, nil
}
var seqSet imap.SeqSet
seqSet.AddNum(seqNums...)
if _, err := cl.c.Move(seqSet, destFolder).Wait(); err != nil {
return 0, fmt.Errorf("IMAP move: %w", err)
}
return len(seqNums), nil
}
// MoveSpecificMessages selektiert den Inbox-Ordner und verschiebt die angegebenen Sequenznummern.
func (cl *Client) MoveSpecificMessages(seqNums []uint32, destFolder string) error {
folder := cl.folder
if folder == "" {
folder = "INBOX"
}
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
return fmt.Errorf("IMAP select: %w", err)
}
return cl.MoveMessages(seqNums, destFolder)
}
// CleanupOldEmails löscht Emails im Ordner, die älter als retentionDays sind.
// Gibt die Anzahl gelöschter Nachrichten zurück. retentionDays <= 0 ist ein No-op.
func (cl *Client) CleanupOldEmails(folder string, retentionDays int) (int, error) {
if retentionDays <= 0 {
return 0, nil
}
if folder == "" {
folder = "INBOX"
}
cutoff := time.Now().AddDate(0, 0, -retentionDays).Truncate(24 * time.Hour)
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
return 0, fmt.Errorf("IMAP select %s: %w", folder, err)
}
searchData, err := cl.c.Search(&imap.SearchCriteria{Before: cutoff}, nil).Wait()
if err != nil {
return 0, fmt.Errorf("IMAP search: %w", err)
}
seqNums := searchData.AllSeqNums()
if len(seqNums) == 0 {
return 0, nil
}
var seqSet imap.SeqSet
seqSet.AddNum(seqNums...)
storeFlags := &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagDeleted},
}
if _, err := cl.c.Store(seqSet, storeFlags, nil).Collect(); err != nil {
return 0, fmt.Errorf("IMAP store flags: %w", err)
}
if _, err := cl.c.Expunge().Collect(); err != nil {
return 0, fmt.Errorf("IMAP expunge: %w", err)
}
return len(seqNums), nil
}
// FetchWithBody holt bis zu n Emails aus dem angegebenen Ordner mit Text-Body.
// Emails werden in Batches von 50 gefetcht um den IMAP-Server nicht zu überlasten.
func (cl *Client) FetchWithBody(folder string, n uint32) ([]MessageWithBody, error) {
if folder == "" {
folder = "INBOX"
}
selectData, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return nil, fmt.Errorf("IMAP select %s: %w", folder, err)
}
if selectData.NumMessages == 0 {
return nil, nil
}
// Letzte n Nachrichten
total := selectData.NumMessages
start := uint32(1)
if total > n {
start = total - n + 1
}
bodySec := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText}
hdrSec := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader}
var result []MessageWithBody
batchSize := uint32(50)
for i := start; i <= total; i += batchSize {
end := i + batchSize - 1
if end > total {
end = total
}
var seqSet imap.SeqSet
seqSet.AddRange(i, end)
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{
Envelope: true,
BodySection: []*imap.FetchItemBodySection{bodySec, hdrSec},
}).Collect()
if err != nil {
return nil, fmt.Errorf("IMAP fetch batch %d-%d: %w", i, end, err)
}
for _, msg := range msgs {
if msg.Envelope == nil {
continue
}
m := MessageWithBody{Message: parseMessage(msg)}
// Content-Transfer-Encoding aus Header lesen
enc := ""
if hdr := msg.FindBodySection(hdrSec); hdr != nil {
for _, line := range strings.Split(string(hdr), "\n") {
if strings.HasPrefix(strings.ToLower(line), "content-transfer-encoding:") {
enc = strings.TrimSpace(strings.ToLower(strings.SplitN(line, ":", 2)[1]))
}
}
}
if body := msg.FindBodySection(bodySec); body != nil {
m.Body = decodeBody(body, enc)
}
result = append(result, m)
}
}
return result, nil
}
// decodeBody dekodiert einen Email-Body je nach Content-Transfer-Encoding.
func decodeBody(raw []byte, enc string) string {
var text string
switch enc {
case "base64":
cleaned := strings.ReplaceAll(strings.TrimSpace(string(raw)), "\r\n", "")
if decoded, err := base64.StdEncoding.DecodeString(cleaned); err == nil {
text = string(decoded)
} else if decoded, err := base64.RawStdEncoding.DecodeString(cleaned); err == nil {
text = string(decoded)
} else {
text = string(raw) // Fallback: roh
}
case "quoted-printable":
r := quotedprintable.NewReader(strings.NewReader(string(raw)))
if buf := new(strings.Builder); true {
buf.Grow(len(raw))
tmp := make([]byte, 4096)
for {
n, err := r.Read(tmp)
buf.Write(tmp[:n])
if err != nil {
break
}
}
text = buf.String()
}
default:
text = string(raw)
}
// Kürzen auf max 2000 Zeichen
text = strings.TrimSpace(text)
if len(text) > 2000 {
text = text[:2000]
}
return text
}
// parseMessage extrahiert eine Message aus einem FetchMessageBuffer.
func parseMessage(msg *imapclient.FetchMessageBuffer) Message {
m := Message{
Subject: msg.Envelope.Subject,
Date: msg.Envelope.Date.Format("2006-01-02 15:04"),
}
if len(msg.Envelope.From) > 0 {
addr := msg.Envelope.From[0]
if addr.Name != "" {
m.From = fmt.Sprintf("%s <%s@%s>", addr.Name, addr.Mailbox, addr.Host)
} else {
m.From = fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
}
}
return m
}
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
result := make([]Message, 0, len(msgs))
for _, msg := range msgs {

View File

@@ -0,0 +1,153 @@
// email/idle.go IMAP IDLE Watcher für Echtzeit-Email-Benachrichtigungen
package email
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"sync/atomic"
"time"
imap "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"my-brain-importer/internal/config"
)
// IdleWatcher überwacht einen IMAP-Account per IDLE auf neue Nachrichten.
type IdleWatcher struct {
acc config.EmailAccount
onNew func(accountName, summary string)
fetching atomic.Bool
}
// NewIdleWatcher erstellt einen IdleWatcher für einen einzelnen Account.
// onNew wird aufgerufen wenn neue Emails gefunden wurden (mit Account-Name und Zusammenfassung).
func NewIdleWatcher(acc config.EmailAccount, onNew func(accountName, summary string)) *IdleWatcher {
return &IdleWatcher{acc: acc, onNew: onNew}
}
// Run startet die IDLE-Schleife. Blockiert bis ctx abgebrochen wird.
func (w *IdleWatcher) Run(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
slog.Info("IDLE: Verbinde", "account", accountLabel(w.acc), "host", w.acc.Host)
if err := w.runOnce(ctx); err != nil {
slog.Warn("IDLE: Fehler, Neuverbindung in 60s", "account", accountLabel(w.acc), "fehler", err)
select {
case <-ctx.Done():
return
case <-time.After(60 * time.Second):
}
}
}
}
func (w *IdleWatcher) runOnce(ctx context.Context) error {
// numMsgs wird atomar geschrieben/gelesen: UnilateralDataHandler läuft in einem
// separaten Goroutine (imapclient-intern), IDLE-Loop liest im Hauptgoroutine.
var numMsgs atomic.Uint32
hasNew := make(chan struct{}, 1)
addr := fmt.Sprintf("%s:%d", w.acc.Host, w.acc.Port)
options := &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
if data.NumMessages != nil && *data.NumMessages > numMsgs.Load() {
numMsgs.Store(*data.NumMessages)
select {
case hasNew <- struct{}{}:
default:
}
}
},
},
}
var (
c *imapclient.Client
err error
)
switch {
case w.acc.TLS:
tlsCfg := &tls.Config{ServerName: w.acc.Host}
options.TLSConfig = tlsCfg
c, err = imapclient.DialTLS(addr, options)
case w.acc.StartTLS:
tlsCfg := &tls.Config{ServerName: w.acc.Host}
options.TLSConfig = tlsCfg
c, err = imapclient.DialStartTLS(addr, options)
default:
c, err = imapclient.DialInsecure(addr, options)
}
if err != nil {
return fmt.Errorf("verbinden: %w", err)
}
defer func() {
c.Logout().Wait()
c.Close()
}()
if err := c.Login(w.acc.User, w.acc.Password).Wait(); err != nil {
return fmt.Errorf("login: %w", err)
}
folder := w.acc.Folder
if folder == "" {
folder = "INBOX"
}
selectData, err := c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return fmt.Errorf("select: %w", err)
}
numMsgs.Store(selectData.NumMessages)
slog.Info("IDLE: Aktiv", "account", accountLabel(w.acc), "folder", folder, "numMsgs", selectData.NumMessages)
for {
if ctx.Err() != nil {
return nil
}
idleCmd, err := c.Idle()
if err != nil {
return fmt.Errorf("IDLE starten: %w", err)
}
select {
case <-ctx.Done():
idleCmd.Close()
idleCmd.Wait()
return nil
case <-hasNew:
idleCmd.Close()
if err := idleCmd.Wait(); err != nil {
slog.Warn("IDLE Wait Fehler", "account", accountLabel(w.acc), "fehler", err)
}
slog.Info("IDLE: Neue Email erkannt", "account", accountLabel(w.acc))
// Nur einen gleichzeitigen Fetch erlauben
if !w.fetching.Swap(true) {
go func() {
defer w.fetching.Store(false)
w.notifyNewEmail()
}()
}
}
}
}
func (w *IdleWatcher) notifyNewEmail() {
summary, err := SummarizeUnreadAccount(w.acc)
if err != nil {
slog.Error("IDLE: Email-Zusammenfassung fehlgeschlagen", "account", accountLabel(w.acc), "fehler", err)
return
}
if summary == "📭 Keine ungelesenen Emails." {
return
}
w.onNew(accountLabel(w.acc), summary)
}

View File

@@ -11,60 +11,510 @@ import (
openai "github.com/sashabaranov/go-openai"
"my-brain-importer/internal/config"
"my-brain-importer/internal/triage"
)
// Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen.
func Summarize() (string, error) {
return fetchAndSummarize(20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return "", fmt.Errorf("Kein Email-Account konfiguriert")
}
if len(accounts) == 1 {
return fetchAndSummarizeAccount(accounts[0], 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
}
var parts []string
for _, acc := range accounts {
result, err := fetchAndSummarizeAccount(acc, 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
if err != nil {
slog.Error("Email-Fehler", "account", acc.Name, "fehler", err)
parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
continue
}
parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result))
}
return strings.Join(parts, "\n\n"), nil
}
// SummarizeUnread fasst ungelesene Emails zusammen.
// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben.
// SummarizeUnread fasst ungelesene Emails für alle konfigurierten Accounts zusammen.
// Wenn email.processed_folder konfiguriert ist, werden Emails danach dorthin verschoben.
func SummarizeUnread() (string, error) {
cl, err := Connect()
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return "", fmt.Errorf("Kein Email-Account konfiguriert")
}
if len(accounts) == 1 {
return SummarizeUnreadAccount(accounts[0])
}
var parts []string
allEmpty := true
for _, acc := range accounts {
result, err := SummarizeUnreadAccount(acc)
if err != nil {
slog.Error("Email-Fehler", "account", acc.Name, "fehler", err)
parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
continue
}
if result != "📭 Keine ungelesenen Emails." {
allEmpty = false
parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result))
}
}
if allEmpty {
return "📭 Keine ungelesenen Emails.", nil
}
return strings.Join(parts, "\n\n"), nil
}
// SummarizeUnreadAccount fasst ungelesene Emails für einen bestimmten Account zusammen.
// Wenn triage_folder konfiguriert ist, werden unwichtige Emails vorher aussortiert.
func SummarizeUnreadAccount(acc config.EmailAccount) (string, error) {
// Phase 1: Triage Emails sortieren (eigene Verbindung)
if acc.TriageUnimportantFolder != "" || acc.TriageImportantFolder != "" {
if err := triageUnread(acc); err != nil {
slog.Warn("[Triage] fehlgeschlagen, übersprungen", "account", accountLabel(acc), "fehler", err)
}
}
// Phase 2: Zusammenfassung der verbleibenden wichtigen Emails (frische Verbindung)
cl, err := ConnectAccount(acc)
if err != nil {
return "", fmt.Errorf("Email-Verbindung: %w", err)
return "", fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
}
defer cl.Close()
processedFolder := config.Cfg.Email.ProcessedFolder
var msgs []Message
var seqNums []uint32
if processedFolder != "" {
if acc.ProcessedFolder != "" {
msgs, seqNums, err = cl.FetchUnreadSeqNums()
} else {
msgs, err = cl.FetchUnread()
}
if err != nil {
return "", fmt.Errorf("Emails abrufen: %w", err)
return "", fmt.Errorf("Emails abrufen (%s): %w", accountLabel(acc), err)
}
if len(msgs) == 0 {
return "📭 Keine ungelesenen Emails.", nil
}
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread")
result, err := summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs), "typ", "unread")
result, err := summarizeWithLLMModel(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.", accountModel(acc))
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)
if acc.ProcessedFolder != "" && len(seqNums) > 0 {
if moveErr := cl.MoveMessages(seqNums, acc.ProcessedFolder); moveErr != nil {
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", acc.ProcessedFolder)
} else {
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", processedFolder)
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", acc.ProcessedFolder)
}
}
return result, nil
}
// TriageRecentAllAccounts klassifiziert die letzten n Emails aller Accounts manuell
// und verschiebt sie in die konfigurierten Triage-Ordner.
func TriageRecentAllAccounts(n uint32) (string, error) {
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return "", fmt.Errorf("kein Email-Account konfiguriert")
}
var lines []string
for _, acc := range accounts {
if acc.TriageImportantFolder == "" && acc.TriageUnimportantFolder == "" {
lines = append(lines, fmt.Sprintf("⚠️ **%s:** kein triage_important_folder / triage_unimportant_folder konfiguriert", accountLabel(acc)))
continue
}
wichtig, unwichtig, err := triageRecentAccount(acc, n)
if err != nil {
lines = append(lines, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
continue
}
lines = append(lines, fmt.Sprintf("✅ **%s:** %d wichtig → `%s`, %d unwichtig → `%s`",
accountLabel(acc), wichtig, acc.TriageImportantFolder, unwichtig, acc.TriageUnimportantFolder))
}
return strings.Join(lines, "\n"), nil
}
// triageRecentAccount klassifiziert die letzten n Emails eines Accounts.
// Gibt Anzahl wichtiger und unwichtiger Emails zurück.
func triageRecentAccount(acc config.EmailAccount, n uint32) (wichtig, unwichtig int, err error) {
cl, err := ConnectAccount(acc)
if err != nil {
return 0, 0, fmt.Errorf("verbinden: %w", err)
}
defer cl.Close()
// Ordner vorab anlegen, unabhängig davon ob Emails verschoben werden
if acc.TriageImportantFolder != "" {
if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil {
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err)
}
}
if acc.TriageUnimportantFolder != "" {
if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil {
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err)
}
}
msgs, err := cl.FetchRecentForSelect(n)
if err != nil {
return 0, 0, fmt.Errorf("fetch: %w", err)
}
if len(msgs) == 0 {
return 0, 0, nil
}
model := accountModel(acc)
var wichtigSeqNums, unwichtigSeqNums []uint32
slog.Info("[Triage] Manuell gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
for _, msg := range msgs {
if ClassifyImportance(msg.Message, model) {
wichtigSeqNums = append(wichtigSeqNums, msg.SeqNum)
} else {
unwichtigSeqNums = append(unwichtigSeqNums, msg.SeqNum)
}
}
if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 {
if ensureErr := cl.EnsureFolder(acc.TriageUnimportantFolder); ensureErr != nil {
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", ensureErr)
}
if moveErr := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); moveErr != nil {
slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "fehler", moveErr)
}
}
if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 {
if ensureErr := cl.EnsureFolder(acc.TriageImportantFolder); ensureErr != nil {
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", ensureErr)
}
if moveErr := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); moveErr != nil {
slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "fehler", moveErr)
}
}
return len(wichtigSeqNums), len(unwichtigSeqNums), nil
}
// triageUnread klassifiziert alle ungelesenen Emails eines Accounts und verschiebt
// wichtige in TriageImportantFolder und unwichtige in TriageUnimportantFolder.
// Läuft sequentiell: eine Email nach der anderen.
func triageUnread(acc config.EmailAccount) error {
cl, err := ConnectAccount(acc)
if err != nil {
return fmt.Errorf("verbinden: %w", err)
}
defer cl.Close()
// Ordner vorab anlegen
if acc.TriageImportantFolder != "" {
cl.EnsureFolder(acc.TriageImportantFolder)
}
if acc.TriageUnimportantFolder != "" {
cl.EnsureFolder(acc.TriageUnimportantFolder)
}
msgs, seqNums, err := cl.FetchUnreadSeqNums()
if err != nil {
return fmt.Errorf("fetch: %w", err)
}
if len(msgs) == 0 {
return nil
}
model := accountModel(acc)
var wichtigSeqNums, unwichtigSeqNums []uint32
slog.Info("[Triage] Klassifizierung gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
for i, msg := range msgs {
if ClassifyImportance(msg, model) {
wichtigSeqNums = append(wichtigSeqNums, seqNums[i])
} else {
unwichtigSeqNums = append(unwichtigSeqNums, seqNums[i])
}
}
if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 {
if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil {
slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageUnimportantFolder, "fehler", err)
}
if err := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); err != nil {
slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err)
} else {
slog.Info("[Triage] Unwichtige Emails verschoben", "anzahl", len(unwichtigSeqNums), "ordner", acc.TriageUnimportantFolder)
}
}
if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 {
if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil {
slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageImportantFolder, "fehler", err)
}
if err := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); err != nil {
slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err)
} else {
slog.Info("[Triage] Wichtige Emails verschoben", "anzahl", len(wichtigSeqNums), "ordner", acc.TriageImportantFolder)
}
}
return nil
}
// ClassifyImportance klassifiziert eine einzelne Email als wichtig (true) oder unwichtig (false).
// Sucht zuerst ähnliche vergangene Entscheidungen in Qdrant (RAG) und gibt sie als Kontext mit.
// Im Fehlerfall oder bei unklarer Antwort wird true (wichtig) zurückgegeben sicherer Default.
func ClassifyImportance(msg Message, model string) bool {
// RAG: ähnliche vergangene Triage-Entscheidungen als Few-Shot-Beispiele
ragQuery := fmt.Sprintf("Von: %s Betreff: %s", msg.From, msg.Subject)
examples := triage.SearchSimilar(ragQuery)
var examplesText string
if len(examples) > 0 {
var sb strings.Builder
sb.WriteString("Ähnliche Entscheidungen aus der Vergangenheit:\n")
for _, ex := range examples {
sb.WriteString("- ")
sb.WriteString(ex.Text)
sb.WriteString("\n")
}
sb.WriteString("\n")
examplesText = sb.String()
}
prompt := fmt.Sprintf("%sVon: %s\nBetreff: %s\n\nIst diese Email wichtig? Antworte NUR mit einem einzigen Wort: wichtig oder unwichtig.",
examplesText, msg.From, msg.Subject)
chatClient := config.NewChatClient()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := chatClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: "Du bist ein Email-Filter. Antworte immer nur mit einem einzigen Wort: wichtig oder unwichtig."},
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
Temperature: 0.1,
MaxTokens: 300,
})
if err != nil {
slog.Warn("[Triage] LLM-Fehler, Email als wichtig eingestuft", "betreff", msg.Subject, "fehler", err)
return true
}
if len(resp.Choices) == 0 {
return true
}
raw := resp.Choices[0].Message.Content
// Reasoning-Modelle (z.B. Qwen3) geben Antwort nach </think>-Tag aus
if idx := strings.LastIndex(raw, "</think>"); idx >= 0 {
raw = raw[idx+len("</think>"):]
}
answer := strings.ToLower(strings.TrimSpace(raw))
isImportant := !strings.Contains(answer, "unwichtig")
slog.Info("[Triage] Email klassifiziert",
"betreff", msg.Subject,
"von", msg.From,
"wichtig", isImportant,
"rag_beispiele", len(examples),
"antwort", answer,
)
// Entscheidung für künftiges Lernen in Qdrant speichern
if err := triage.StoreDecision(msg.Subject, msg.From, isImportant); err != nil {
slog.Warn("[Triage] Entscheidung nicht gespeichert", "fehler", err)
}
return isImportant
}
// ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines.
func ExtractReminders() (string, error) {
return fetchAndSummarize(30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
accounts := config.AllEmailAccounts()
if len(accounts) == 0 {
return "", fmt.Errorf("Kein Email-Account konfiguriert")
}
if len(accounts) == 1 {
return fetchAndSummarizeAccount(accounts[0], 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
}
var parts []string
for _, acc := range accounts {
result, err := fetchAndSummarizeAccount(acc, 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
if err != nil {
slog.Error("Email-Fehler", "account", acc.Name, "fehler", err)
parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
continue
}
parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result))
}
return strings.Join(parts, "\n\n"), nil
}
// MoveUnread verschiebt alle ungelesenen Emails eines Accounts in den Zielordner.
// Gibt die Anzahl verschobener Emails zurück.
func MoveUnread(acc config.EmailAccount, destFolder string) (int, error) {
cl, err := ConnectAccount(acc)
if err != nil {
return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
}
defer cl.Close()
_, seqNums, err := cl.FetchUnreadSeqNums()
if err != nil {
return 0, fmt.Errorf("Emails abrufen: %w", err)
}
if len(seqNums) == 0 {
return 0, nil
}
if err := cl.MoveMessages(seqNums, destFolder); err != nil {
return 0, fmt.Errorf("Verschieben nach %s: %w", destFolder, err)
}
return len(seqNums), nil
}
// AccountSelectMessages enthält ungelesene Emails eines Accounts für die Discord-Auswahl.
type AccountSelectMessages struct {
Account config.EmailAccount
AccIndex int
Messages []SelectMessage
}
// FetchUnreadForSelectAllAccounts holt ungelesene Emails aller Accounts für die Discord-Auswahl.
func FetchUnreadForSelectAllAccounts() ([]AccountSelectMessages, error) {
accounts := config.AllEmailAccounts()
var result []AccountSelectMessages
for i, acc := range accounts {
cl, err := ConnectAccount(acc)
if err != nil {
return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err)
}
msgs, err := cl.FetchUnreadForSelect()
cl.Close()
if err != nil {
return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err)
}
result = append(result, AccountSelectMessages{
Account: acc,
AccIndex: i,
Messages: msgs,
})
}
return result, nil
}
// FetchRecentForSelectAllAccounts holt die letzten n Emails aller Accounts für die Discord-Auswahl.
func FetchRecentForSelectAllAccounts(n uint32) ([]AccountSelectMessages, error) {
accounts := config.AllEmailAccounts()
var result []AccountSelectMessages
for i, acc := range accounts {
cl, err := ConnectAccount(acc)
if err != nil {
return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err)
}
msgs, err := cl.FetchRecentForSelect(n)
cl.Close()
if err != nil {
return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err)
}
result = append(result, AccountSelectMessages{
Account: acc,
AccIndex: i,
Messages: msgs,
})
}
return result, nil
}
// MoveOldEmailsAllAccounts verschiebt alle Emails aller Accounts, die älter als olderThanDays Tage sind, nach destFolder.
// Gibt die Gesamtanzahl verschobener Emails zurück.
func MoveOldEmailsAllAccounts(destFolder string, olderThanDays int) (int, error) {
accounts := config.AllEmailAccounts()
total := 0
for _, acc := range accounts {
cl, err := ConnectAccount(acc)
if err != nil {
return total, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err)
}
n, err := cl.MoveOldMessages(acc.Folder, destFolder, olderThanDays)
cl.Close()
if err != nil {
return total, fmt.Errorf("Verschieben %s: %w", accountLabel(acc), err)
}
total += n
}
return total, nil
}
// MoveSpecificUnread verschiebt spezifische Emails (per Sequenznummer) eines Accounts in den Zielordner.
func MoveSpecificUnread(accIndex int, seqNums []uint32, destFolder string) (int, error) {
accounts := config.AllEmailAccounts()
if accIndex < 0 || accIndex >= len(accounts) {
return 0, fmt.Errorf("ungültiger Account-Index %d", accIndex)
}
acc := accounts[accIndex]
cl, err := ConnectAccount(acc)
if err != nil {
return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
}
defer cl.Close()
if err := cl.MoveSpecificMessages(seqNums, destFolder); err != nil {
return 0, err
}
return len(seqNums), nil
}
// CleanupArchiveFolders löscht abgelaufene Emails aus allen konfigurierten Archivordnern.
// Gibt eine menschenlesbare Zusammenfassung zurück.
func CleanupArchiveFolders() (string, error) {
accounts := config.AllEmailAccounts()
var lines []string
var errs []string
total := 0
for _, acc := range accounts {
for _, af := range acc.ArchiveFolders {
if af.RetentionDays <= 0 {
continue
}
cl, err := ConnectAccount(acc)
if err != nil {
errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err))
continue
}
n, err := cl.CleanupOldEmails(af.IMAPFolder, af.RetentionDays)
cl.Close()
if err != nil {
errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err))
continue
}
if n > 0 {
lines = append(lines, fmt.Sprintf("🗑️ %s/%s: %d Email(s) gelöscht (älter als %d Tage)", accountLabel(acc), af.Name, n, af.RetentionDays))
total += n
}
}
}
var result string
if len(lines) == 0 && len(errs) == 0 {
result = "Kein Aufräumen notwendig."
} else {
result = strings.Join(lines, "\n")
}
var combinedErr error
if len(errs) > 0 {
combinedErr = fmt.Errorf("%s", strings.Join(errs, "; "))
}
slog.Info("Archiv-Aufräumen abgeschlossen", "gelöscht", total, "fehler", len(errs))
return result, combinedErr
}
// SummarizeMessages fasst eine übergebene Liste von Nachrichten zusammen (für Tests ohne IMAP).
@@ -72,8 +522,8 @@ func SummarizeMessages(msgs []Message, instruction string) (string, error) {
return summarizeWithLLM(msgs, instruction)
}
func fetchAndSummarize(n uint32, instruction string) (string, error) {
cl, err := Connect()
func fetchAndSummarizeAccount(acc config.EmailAccount, n uint32, instruction string) (string, error) {
cl, err := ConnectAccount(acc)
if err != nil {
return "", fmt.Errorf("Email-Verbindung: %w", err)
}
@@ -87,12 +537,27 @@ func fetchAndSummarize(n uint32, instruction string) (string, error) {
return "📭 Keine Emails gefunden.", nil
}
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs))
return summarizeWithLLM(msgs, instruction)
slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
return summarizeWithLLMModel(msgs, instruction, accountModel(acc))
}
// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück.
// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist.
// accountLabel gibt einen lesbaren Namen für einen Account zurück.
func accountLabel(acc config.EmailAccount) string {
if acc.Name != "" {
return acc.Name
}
return acc.User
}
// accountModel gibt das konfigurierte LLM-Modell für einen Account zurück.
func accountModel(acc config.EmailAccount) string {
if acc.Model != "" {
return acc.Model
}
return config.Cfg.Chat.Model
}
// emailModel gibt das konfigurierte Modell für den Legacy-Account zurück.
func emailModel() string {
if config.Cfg.Email.Model != "" {
return config.Cfg.Email.Model
@@ -110,8 +575,11 @@ func formatEmailList(msgs []Message) string {
}
func summarizeWithLLM(msgs []Message, instruction string) (string, error) {
return summarizeWithLLMModel(msgs, instruction, emailModel())
}
func summarizeWithLLMModel(msgs []Message, instruction, model string) (string, error) {
emailList := formatEmailList(msgs)
model := emailModel()
chatClient := config.NewChatClient()
ctx := context.Background()

View File

@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"my-brain-importer/internal/config"
)
var testMessages = []Message{
@@ -70,3 +72,46 @@ func TestMessage_DateFormat(t *testing.T) {
t.Errorf("Datumsformat ungültig: %v", err)
}
}
func TestAccountLabel_WithName(t *testing.T) {
acc := config.EmailAccount{Name: "Privat", User: "user@example.de"}
if got := accountLabel(acc); got != "Privat" {
t.Errorf("accountLabel: erwartet %q, got %q", "Privat", got)
}
}
func TestAccountLabel_FallsBackToUser(t *testing.T) {
acc := config.EmailAccount{User: "user@example.de"}
if got := accountLabel(acc); got != "user@example.de" {
t.Errorf("accountLabel: erwartet %q, got %q", "user@example.de", got)
}
}
func TestAccountModel_WithModel(t *testing.T) {
acc := config.EmailAccount{Model: "custom-model"}
if got := accountModel(acc); got != "custom-model" {
t.Errorf("accountModel: erwartet %q, got %q", "custom-model", got)
}
}
func TestAccountModel_FallsBackToChatModel(t *testing.T) {
orig := config.Cfg
defer func() { config.Cfg = orig }()
config.Cfg.Chat.Model = "default-model"
acc := config.EmailAccount{} // kein Model gesetzt
if got := accountModel(acc); got != "default-model" {
t.Errorf("accountModel: erwartet chat.model %q, got %q", "default-model", got)
}
}
func TestSummarizeUnread_NoAccountsConfigured(t *testing.T) {
orig := config.Cfg
defer func() { config.Cfg = orig }()
config.Cfg = config.Config{} // leere Config, kein Email-Account
_, err := SummarizeUnread()
if err == nil {
t.Error("erwartet Fehler wenn kein Account konfiguriert")
}
}

View File

@@ -0,0 +1,168 @@
// rss/watcher.go Überwacht RSS-Feeds und importiert neue Artikel in Qdrant
package rss
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/mmcdole/gofeed"
"my-brain-importer/internal/brain"
"my-brain-importer/internal/config"
)
// FeedResult fasst das Ergebnis eines Feed-Imports zusammen.
type FeedResult struct {
URL string
Title string
Imported int
Err error
}
// IngestFeed fetcht einen RSS-Feed und importiert neue Artikel in Qdrant.
// Gibt Anzahl der importierten Artikel zurück.
func IngestFeed(feedURL string) (int, string, error) {
fp := gofeed.NewParser()
fp.Client = gofeed.NewParser().Client // default HTTP client with timeout
feed, err := fp.ParseURL(feedURL)
if err != nil {
return 0, "", fmt.Errorf("Feed-Parsing fehlgeschlagen: %w", err)
}
feedTitle := feed.Title
if feedTitle == "" {
feedTitle = feedURL
}
imported := 0
for _, item := range feed.Items {
text := buildArticleText(item)
if len(strings.TrimSpace(text)) < 20 {
continue
}
source := fmt.Sprintf("rss/%s", feedURL)
if item.Link != "" {
source = item.Link
}
if err := brain.IngestText(text, source, "rss"); err != nil {
slog.Warn("RSS: Artikel konnte nicht importiert werden", "url", item.Link, "fehler", err)
continue
}
imported++
}
return imported, feedTitle, nil
}
// buildArticleText formatiert einen RSS-Artikel als importierbaren Text.
func buildArticleText(item *gofeed.Item) string {
var sb strings.Builder
if item.Title != "" {
fmt.Fprintf(&sb, "# %s\n\n", item.Title)
}
if item.Published != "" {
fmt.Fprintf(&sb, "Veröffentlicht: %s\n", item.Published)
}
if item.Link != "" {
fmt.Fprintf(&sb, "URL: %s\n\n", item.Link)
}
if item.Description != "" {
sb.WriteString(strings.TrimSpace(item.Description))
}
return sb.String()
}
// IngestAllFeeds importiert alle konfigurierten RSS-Feeds.
// Gibt eine Zusammenfassung der Ergebnisse zurück.
func IngestAllFeeds() []FeedResult {
feeds := config.Cfg.RSSFeeds
if len(feeds) == 0 {
return nil
}
results := make([]FeedResult, 0, len(feeds))
for _, f := range feeds {
n, title, err := IngestFeed(f.URL)
results = append(results, FeedResult{
URL: f.URL,
Title: title,
Imported: n,
Err: err,
})
}
return results
}
// FormatResults gibt eine Discord-formatierte Zusammenfassung zurück.
func FormatResults(results []FeedResult) string {
if len(results) == 0 {
return "📭 Keine RSS-Feeds konfiguriert."
}
var sb strings.Builder
for _, r := range results {
if r.Err != nil {
fmt.Fprintf(&sb, "❌ **%s**: %v\n", r.URL, r.Err)
} else {
name := r.Title
if name == "" {
name = r.URL
}
fmt.Fprintf(&sb, "✅ **%s**: %d Artikel importiert\n", name, r.Imported)
}
}
return strings.TrimSpace(sb.String())
}
// Watcher überwacht alle konfigurierten RSS-Feeds in regelmäßigen Abständen.
type Watcher struct {
OnResults func(summary string)
}
// Run startet die RSS-Überwachungsschleife. Blockiert bis ctx abgebrochen wird.
func (w *Watcher) Run(ctx context.Context) {
feeds := config.Cfg.RSSFeeds
if len(feeds) == 0 {
slog.Info("RSS-Watcher: Keine Feeds konfiguriert, beende")
return
}
// Ersten Durchlauf sofort starten
w.runOnce()
// Dann Timer basierend auf minimalem Intervall
minInterval := 24 * time.Hour
for _, f := range feeds {
h := f.IntervalHours
if h <= 0 {
h = 24
}
d := time.Duration(h) * time.Hour
if d < minInterval {
minInterval = d
}
}
ticker := time.NewTicker(minInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce()
}
}
}
func (w *Watcher) runOnce() {
results := IngestAllFeeds()
if w.OnResults != nil && len(results) > 0 {
summary := FormatResults(results)
if summary != "" {
w.OnResults(summary)
}
}
}

View File

@@ -41,7 +41,8 @@ func AskQuery(question string, history []agents.HistoryMessage) (string, []Knowl
contextText := buildContext(chunks)
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent.
coreMemory := LoadCoreMemory()
systemPromptBase := `Du bist ein hilfreicher persönlicher Assistent.
Beantworte Fragen primär anhand der bereitgestellten Informationen aus der Wissensdatenbank.
Ergänze fehlende Details mit deinem eigenen Wissen, kennzeichne dies aber klar mit "Aus meinem Wissen:".
@@ -50,6 +51,10 @@ WICHTIGE REGELN:
- Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es deutlich
- Antworte auf Deutsch
- Sei präzise und direkt`
systemPrompt := systemPromptBase
if coreMemory != "" {
systemPrompt = systemPromptBase + "\n\n## Fakten über den Nutzer:\n" + coreMemory
}
userPrompt := fmt.Sprintf(`Hier sind die relevanten Informationen aus meiner Wissensdatenbank:

View File

@@ -0,0 +1,54 @@
// core_memory.go Persistente Kernfakten über den Nutzer (core_memory.md)
package brain
import (
"fmt"
"os"
"path/filepath"
"strings"
"my-brain-importer/internal/config"
)
// CoreMemoryPath gibt den Pfad zur core_memory.md-Datei zurück.
func CoreMemoryPath() string {
return filepath.Join(config.Cfg.BrainRoot, "core_memory.md")
}
// LoadCoreMemory liest den Inhalt der core_memory.md-Datei.
// Gibt leeren String zurück wenn die Datei nicht existiert.
func LoadCoreMemory() string {
data, err := os.ReadFile(CoreMemoryPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// AppendCoreMemory fügt einen Fakt zur core_memory.md-Datei hinzu.
func AppendCoreMemory(text string) error {
path := CoreMemoryPath()
// Datei erstellen falls nicht vorhanden, sonst anhängen
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("core_memory.md öffnen: %w", err)
}
defer f.Close()
// Führenden Bindestrich ergänzen wenn nicht vorhanden
line := strings.TrimSpace(text)
if !strings.HasPrefix(line, "-") {
line = "- " + line
}
_, err = fmt.Fprintf(f, "%s\n", line)
return err
}
// ShowCoreMemory gibt den Inhalt der core_memory.md als formatierte Nachricht zurück.
func ShowCoreMemory() string {
content := LoadCoreMemory()
if content == "" {
return "📭 Keine Kernfakten gespeichert. Nutze `/memory profile <text>` um Fakten hinzuzufügen."
}
return fmt.Sprintf("🧠 **Kerngedächtnis:**\n```\n%s\n```", content)
}

View File

@@ -256,3 +256,30 @@ func IngestChatMessage(text, author, source string) error {
}
func boolPtr(b bool) *bool { return &b }
// IngestText speichert einen beliebigen Text mit Quelle und Typ in Qdrant.
// Verwendet die gleiche Chunking-Logik wie der Markdown-Ingest.
func IngestText(text, source, docType string) error {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
conn := config.NewQdrantConn()
defer conn.Close()
ensureCollection(ctx, pb.NewCollectionsClient(conn))
pointsClient := pb.NewPointsClient(conn)
var chunks []chunk
for _, part := range splitLongSection(text) {
part = strings.TrimSpace(part)
if len(part) < 20 {
continue
}
chunks = append(chunks, chunk{Text: part, Source: source, Type: docType})
}
if len(chunks) == 0 {
return nil
}
return ingestChunks(ctx, embClient, pointsClient, chunks)
}

View File

@@ -0,0 +1,98 @@
// ingest_email.go Importiert Emails aus einem IMAP-Ordner in Qdrant
package brain
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
pb "github.com/qdrant/go-client/qdrant"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/agents/tool/email"
"my-brain-importer/internal/config"
)
// IngestEmailFolder importiert alle Emails aus einem IMAP-Ordner in Qdrant.
// Gibt Anzahl der importierten Emails zurück.
// maxEmails = 0 bedeutet: alle (bis max. 500).
func IngestEmailFolder(acc config.EmailAccount, folder string, maxEmails uint32) (int, error) {
if maxEmails == 0 {
maxEmails = 500
}
cl, err := email.ConnectAccount(acc)
if err != nil {
return 0, fmt.Errorf("IMAP-Verbindung: %w", err)
}
defer cl.Close()
slog.Info("Email-Ingest: Lade Emails", "account", acc.Name, "folder", folder, "max", maxEmails)
msgs, err := cl.FetchWithBody(folder, maxEmails)
if err != nil {
return 0, fmt.Errorf("Emails laden: %w", err)
}
if len(msgs) == 0 {
return 0, nil
}
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
conn := config.NewQdrantConn()
defer conn.Close()
ensureCollection(ctx, pb.NewCollectionsClient(conn))
pointsClient := pb.NewPointsClient(conn)
var chunks []chunk
for _, m := range msgs {
text := formatEmailForIngest(m)
if len(strings.TrimSpace(text)) < 20 {
continue
}
source := fmt.Sprintf("email/%s/%s", folder, m.Date)
chunks = append(chunks, chunk{Text: text, Source: source, Type: "email"})
}
if len(chunks) == 0 {
return 0, nil
}
slog.Info("Email-Ingest: Starte Embedding", "emails", len(msgs), "chunks", len(chunks))
// In Batches von 20 ingesten (Embeddings können langsam sein)
ingested := 0
for i := 0; i < len(chunks); i += 20 {
end := i + 20
if end > len(chunks) {
end = len(chunks)
}
batch := chunks[i:end]
if err := ingestChunks(ctx, embClient, pointsClient, batch); err != nil {
slog.Warn("Email-Ingest Batch-Fehler", "batch_start", i, "fehler", err)
continue
}
ingested += len(batch)
slog.Info("Email-Ingest Fortschritt", "ingested", ingested, "total", len(chunks))
time.Sleep(50 * time.Millisecond)
}
return ingested, nil
}
// formatEmailForIngest formatiert eine Email als durchsuchbaren Text.
func formatEmailForIngest(m email.MessageWithBody) string {
var sb strings.Builder
fmt.Fprintf(&sb, "Betreff: %s\n", m.Subject)
fmt.Fprintf(&sb, "Von: %s\n", m.From)
fmt.Fprintf(&sb, "Datum: %s\n", m.Date)
if m.Body != "" {
sb.WriteString("\n")
sb.WriteString(m.Body)
}
return sb.String()
}

View File

@@ -0,0 +1,82 @@
// ingest_pdf.go Extrahiert Text aus einer PDF-Datei und importiert ihn in Qdrant
package brain
import (
"context"
"fmt"
"strings"
"github.com/ledongthuc/pdf"
pb "github.com/qdrant/go-client/qdrant"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/config"
)
// IngestPDF extrahiert Text aus einer PDF-Datei und importiert ihn in Qdrant.
// source ist der Anzeigename der Quelle (z.B. Dateiname).
// Gibt Anzahl der importierten Chunks zurück.
func IngestPDF(filePath, source string) (int, error) {
text, err := extractPDFText(filePath)
if err != nil {
return 0, fmt.Errorf("PDF-Parsing fehlgeschlagen: %w", err)
}
text = strings.TrimSpace(text)
if len(text) < 20 {
return 0, fmt.Errorf("kein verwertbarer Text in PDF gefunden")
}
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
conn := config.NewQdrantConn()
defer conn.Close()
ensureCollection(ctx, pb.NewCollectionsClient(conn))
pointsClient := pb.NewPointsClient(conn)
var chunks []chunk
for _, part := range splitLongSection(text) {
part = strings.TrimSpace(part)
if len(part) < 20 {
continue
}
chunks = append(chunks, chunk{Text: part, Source: source, Type: "pdf"})
}
if len(chunks) == 0 {
return 0, fmt.Errorf("kein verwertbarer Inhalt nach Aufteilung")
}
if err := ingestChunks(ctx, embClient, pointsClient, chunks); err != nil {
return 0, fmt.Errorf("Ingest fehlgeschlagen: %w", err)
}
return len(chunks), nil
}
// extractPDFText liest alle Seiten einer PDF-Datei und gibt den Text zurück.
func extractPDFText(filePath string) (string, error) {
f, r, err := pdf.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
var sb strings.Builder
totalPages := r.NumPage()
for pageNum := 1; pageNum <= totalPages; pageNum++ {
page := r.Page(pageNum)
if page.V.IsNull() {
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
continue
}
sb.WriteString(text)
sb.WriteString("\n")
}
return sb.String(), nil
}

View File

@@ -0,0 +1,124 @@
// ingest_url.go Fetcht eine URL und importiert den Textinhalt in Qdrant
package brain
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
pb "github.com/qdrant/go-client/qdrant"
"golang.org/x/net/html"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/config"
)
// IngestURL fetcht eine URL, extrahiert den Textinhalt und importiert ihn in Qdrant.
// Gibt Anzahl der importierten Chunks zurück.
func IngestURL(rawURL string) (int, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(rawURL)
if err != nil {
return 0, fmt.Errorf("HTTP-Fehler: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
contentType := resp.Header.Get("Content-Type")
var text string
if strings.Contains(contentType, "text/html") {
text, err = extractHTMLText(resp.Body)
if err != nil {
return 0, fmt.Errorf("HTML-Parsing fehlgeschlagen: %w", err)
}
} else {
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // max 1MB
if err != nil {
return 0, fmt.Errorf("Lesen fehlgeschlagen: %w", err)
}
text = string(raw)
}
text = strings.TrimSpace(text)
if len(text) < 20 {
return 0, fmt.Errorf("kein verwertbarer Inhalt gefunden")
}
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
conn := config.NewQdrantConn()
defer conn.Close()
ensureCollection(ctx, pb.NewCollectionsClient(conn))
pointsClient := pb.NewPointsClient(conn)
var chunks []chunk
for _, part := range splitLongSection(text) {
part = strings.TrimSpace(part)
if len(part) < 20 {
continue
}
chunks = append(chunks, chunk{Text: part, Source: rawURL, Type: "url"})
}
if len(chunks) == 0 {
return 0, fmt.Errorf("kein verwertbarer Inhalt nach Aufteilung")
}
if err := ingestChunks(ctx, embClient, pointsClient, chunks); err != nil {
return 0, fmt.Errorf("Ingest fehlgeschlagen: %w", err)
}
return len(chunks), nil
}
// extractHTMLText extrahiert sichtbaren Text aus einem HTML-Dokument.
func extractHTMLText(r io.Reader) (string, error) {
doc, err := html.Parse(r)
if err != nil {
return "", err
}
var sb strings.Builder
extractTextNode(doc, &sb)
// Mehrfach-Leerzeilen reduzieren
lines := strings.Split(sb.String(), "\n")
var cleaned []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.Join(cleaned, "\n"), nil
}
// skipTags sind HTML-Elemente deren Inhalt nicht extrahiert wird.
var skipTags = map[string]bool{
"script": true, "style": true, "noscript": true,
"head": true, "meta": true, "link": true,
"nav": true, "footer": true, "header": true,
}
func extractTextNode(n *html.Node, sb *strings.Builder) {
if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" {
sb.WriteString(text)
sb.WriteString("\n")
}
return
}
if n.Type == html.ElementNode && skipTags[n.Data] {
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
extractTextNode(c, sb)
}
}

108
internal/brain/knowledge.go Normal file
View File

@@ -0,0 +1,108 @@
// knowledge.go Listet und löscht Einträge in der Qdrant-Wissensdatenbank
package brain
import (
"context"
"fmt"
"sort"
pb "github.com/qdrant/go-client/qdrant"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/config"
)
// ListSources gibt alle eindeutigen Quellen in der Wissensdatenbank zurück.
// Limit begrenzt die Anzahl der zu scrollenden Punkte (0 = Standard 1000).
func ListSources(limit uint32) ([]string, error) {
if limit == 0 {
limit = 1000
}
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
conn := config.NewQdrantConn()
defer conn.Close()
pointsClient := pb.NewPointsClient(conn)
seen := map[string]bool{}
var offset *pb.PointId
for {
req := &pb.ScrollPoints{
CollectionName: config.Cfg.Qdrant.Collection,
WithPayload: &pb.WithPayloadSelector{
SelectorOptions: &pb.WithPayloadSelector_Include{
Include: &pb.PayloadIncludeSelector{Fields: []string{"source"}},
},
},
Limit: uint32Ptr(250),
}
if offset != nil {
req.Offset = offset
}
result, err := pointsClient.Scroll(ctx, req)
if err != nil {
return nil, fmt.Errorf("Scroll fehlgeschlagen: %w", err)
}
for _, pt := range result.Result {
if src := pt.Payload["source"].GetStringValue(); src != "" {
seen[src] = true
}
}
if result.NextPageOffset == nil || uint32(len(seen)) >= limit {
break
}
offset = result.NextPageOffset
}
sources := make([]string, 0, len(seen))
for s := range seen {
sources = append(sources, s)
}
sort.Strings(sources)
return sources, nil
}
// DeleteBySource löscht alle Punkte mit dem gegebenen Quellennamen aus Qdrant.
// Gibt Anzahl gelöschter Punkte zurück (Qdrant liefert keine genaue Zahl — gibt 0 zurück wenn erfolgreich).
func DeleteBySource(source string) error {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
conn := config.NewQdrantConn()
defer conn.Close()
pointsClient := pb.NewPointsClient(conn)
_, err := pointsClient.Delete(ctx, &pb.DeletePoints{
CollectionName: config.Cfg.Qdrant.Collection,
Points: &pb.PointsSelector{
PointsSelectorOneOf: &pb.PointsSelector_Filter{
Filter: &pb.Filter{
Must: []*pb.Condition{
{
ConditionOneOf: &pb.Condition_Field{
Field: &pb.FieldCondition{
Key: "source",
Match: &pb.Match{
MatchValue: &pb.Match_Keyword{Keyword: source},
},
},
},
},
},
},
},
},
Wait: boolPtr(true),
})
return err
}
func uint32Ptr(v uint32) *uint32 { return &v }

View File

@@ -12,6 +12,30 @@ import (
"gopkg.in/yaml.v3"
)
// ArchiveFolder beschreibt einen IMAP-Archivordner mit optionaler Aufbewahrungsdauer.
type ArchiveFolder struct {
Name string `yaml:"name"` // Anzeigename (z.B. "5Jahre")
IMAPFolder string `yaml:"imap_folder"` // Echter IMAP-Ordnername (z.B. "5Jahre")
RetentionDays int `yaml:"retention_days"` // 0 = dauerhaft behalten
}
// EmailAccount beschreibt einen einzelnen IMAP-Account.
type EmailAccount struct {
Name string `yaml:"name"`
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"`
Model string `yaml:"model"`
ArchiveFolders []ArchiveFolder `yaml:"archive_folders"`
TriageImportantFolder string `yaml:"triage_important_folder"` // Ordner für wichtige Emails (leer = in INBOX lassen)
TriageUnimportantFolder string `yaml:"triage_unimportant_folder"` // Ordner für unwichtige Emails (leer = kein Triage)
}
type Config struct {
Qdrant struct {
Host string `yaml:"host"`
@@ -32,22 +56,30 @@ type Config struct {
} `yaml:"chat"`
Discord struct {
Token string `yaml:"token"`
GuildID string `yaml:"guild_id"`
Token string `yaml:"token"`
GuildID string `yaml:"guild_id"`
AllowedUsers []string `yaml:"allowed_users"` // Wenn gesetzt, dürfen nur diese User-IDs den Bot nutzen
} `yaml:"discord"`
// Email ist der Legacy-Block für einen einzelnen Account.
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"`
ProcessedFolder string `yaml:"processed_folder"` // Zielordner nach Zusammenfassung (leer = kein Verschieben)
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"`
Model string `yaml:"model"`
ArchiveFolders []ArchiveFolder `yaml:"archive_folders"`
TriageImportantFolder string `yaml:"triage_important_folder"`
TriageUnimportantFolder string `yaml:"triage_unimportant_folder"`
} `yaml:"email"`
// EmailAccounts ermöglicht mehrere IMAP-Accounts. Hat Vorrang vor email:.
EmailAccounts []EmailAccount `yaml:"email_accounts"`
Tasks struct {
StorePath string `yaml:"store_path"`
} `yaml:"tasks"`
@@ -56,15 +88,52 @@ type Config struct {
ChannelID string `yaml:"channel_id"`
EmailIntervalMin int `yaml:"email_interval_min"`
TaskReminderHour int `yaml:"task_reminder_hour"`
CleanupHour int `yaml:"cleanup_hour"` // Uhrzeit für tägliches Archiv-Aufräumen (Standard: 2)
IngestHour int `yaml:"ingest_hour"` // Uhrzeit für nächtlichen Email-Ingest (Standard: 23, 0 = deaktiviert)
} `yaml:"daemon"`
BrainRoot string `yaml:"brain_root"`
TopK uint64 `yaml:"top_k"`
ScoreThreshold float32 `yaml:"score_threshold"`
// RSSFeeds definiert RSS-Feeds die automatisch überwacht werden.
RSSFeeds []RSSFeed `yaml:"rss_feeds"`
}
// RSSFeed beschreibt einen RSS-Feed mit Polling-Intervall.
type RSSFeed struct {
URL string `yaml:"url"`
IntervalHours int `yaml:"interval_hours"` // 0 = Standard 24h
}
var Cfg Config
// AllEmailAccounts gibt alle konfigurierten Email-Accounts zurück.
// Wenn email_accounts konfiguriert ist, hat das Vorrang vor dem Legacy-email:-Block.
func AllEmailAccounts() []EmailAccount {
if len(Cfg.EmailAccounts) > 0 {
return Cfg.EmailAccounts
}
if Cfg.Email.Host == "" {
return nil
}
return []EmailAccount{{
Name: "Email",
Host: Cfg.Email.Host,
Port: Cfg.Email.Port,
User: Cfg.Email.User,
Password: Cfg.Email.Password,
TLS: Cfg.Email.TLS,
StartTLS: Cfg.Email.StartTLS,
Folder: Cfg.Email.Folder,
ProcessedFolder: Cfg.Email.ProcessedFolder,
Model: Cfg.Email.Model,
ArchiveFolders: Cfg.Email.ArchiveFolders,
TriageImportantFolder: Cfg.Email.TriageImportantFolder,
TriageUnimportantFolder: Cfg.Email.TriageUnimportantFolder,
}}
}
// NewQdrantConn öffnet eine gRPC-Verbindung zur Qdrant-Instanz.
// Der Aufrufer ist verantwortlich für conn.Close().
func NewQdrantConn() *grpc.ClientConn {

View File

@@ -0,0 +1,87 @@
package config
import "testing"
func TestAllEmailAccounts_Empty(t *testing.T) {
orig := Cfg
defer func() { Cfg = orig }()
Cfg = Config{}
accounts := AllEmailAccounts()
if len(accounts) != 0 {
t.Errorf("erwartet 0 Accounts, got %d", len(accounts))
}
}
func TestAllEmailAccounts_LegacyFallback(t *testing.T) {
orig := Cfg
defer func() { Cfg = orig }()
Cfg = Config{}
Cfg.Email.Host = "imap.example.de"
Cfg.Email.Port = 143
Cfg.Email.User = "user@example.de"
Cfg.Email.Password = "geheim"
Cfg.Email.Folder = "INBOX"
Cfg.Email.ProcessedFolder = "Processed"
Cfg.Email.Model = "testmodel"
accounts := AllEmailAccounts()
if len(accounts) != 1 {
t.Fatalf("erwartet 1 Account, got %d", len(accounts))
}
a := accounts[0]
if a.Host != "imap.example.de" {
t.Errorf("Host: got %q", a.Host)
}
if a.Port != 143 {
t.Errorf("Port: got %d", a.Port)
}
if a.User != "user@example.de" {
t.Errorf("User: got %q", a.User)
}
if a.ProcessedFolder != "Processed" {
t.Errorf("ProcessedFolder: got %q", a.ProcessedFolder)
}
if a.Model != "testmodel" {
t.Errorf("Model: got %q", a.Model)
}
}
func TestAllEmailAccounts_MultipleAccounts(t *testing.T) {
orig := Cfg
defer func() { Cfg = orig }()
Cfg = Config{}
Cfg.EmailAccounts = []EmailAccount{
{Name: "Privat", Host: "imap1.de", Port: 143},
{Name: "Arbeit", Host: "imap2.de", Port: 993, TLS: true},
}
accounts := AllEmailAccounts()
if len(accounts) != 2 {
t.Fatalf("erwartet 2 Accounts, got %d", len(accounts))
}
if accounts[0].Host != "imap1.de" {
t.Errorf("Account 0 Host: got %q", accounts[0].Host)
}
if accounts[1].Host != "imap2.de" {
t.Errorf("Account 1 Host: got %q", accounts[1].Host)
}
}
func TestAllEmailAccounts_NewTakesPrecedence(t *testing.T) {
orig := Cfg
defer func() { Cfg = orig }()
Cfg = Config{}
Cfg.Email.Host = "legacy.de"
Cfg.EmailAccounts = []EmailAccount{
{Name: "Neu", Host: "new.de"},
}
accounts := AllEmailAccounts()
if len(accounts) != 1 {
t.Fatalf("erwartet 1 Account, got %d", len(accounts))
}
if accounts[0].Host != "new.de" {
t.Errorf("email_accounts sollte Vorrang haben, got host %q", accounts[0].Host)
}
}

View File

@@ -49,11 +49,18 @@ func RunAll() (results []Result, allOK bool) {
check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg)
}
// IMAP
if cfg.Email.Host != "" {
imapAddr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
// IMAP alle konfigurierten Accounts prüfen
for _, acc := range config.AllEmailAccounts() {
if acc.Host == "" {
continue
}
imapAddr := fmt.Sprintf("%s:%d", acc.Host, acc.Port)
label := acc.Name
if label == "" {
label = acc.User
}
ok, msg = tcpCheck(imapAddr)
check("IMAP ("+imapAddr+")", ok, msg)
check("IMAP "+label+" ("+imapAddr+")", ok, msg)
}
return results, allOK

138
internal/triage/triage.go Normal file
View File

@@ -0,0 +1,138 @@
// triage/triage.go Speichert und sucht Email-Triage-Entscheidungen in Qdrant (RAG-Lernen)
// Eigenes Package um Import-Zyklen zwischen brain und email zu vermeiden.
package triage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
pb "github.com/qdrant/go-client/qdrant"
openai "github.com/sashabaranov/go-openai"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/config"
)
// TriageResult repräsentiert ein Suchergebnis aus vergangenen Triage-Entscheidungen.
type TriageResult struct {
Text string
Score float32
}
// StoreDecision speichert eine Triage-Entscheidung in Qdrant.
// Bei gleicher Email (deterministischer ID) wird die Entscheidung überschrieben.
func StoreDecision(subject, from string, isImportant bool) error {
label := "wichtig"
if !isImportant {
label = "unwichtig"
}
text := fmt.Sprintf("Email-Triage | Von: %s | Betreff: %s | Entscheidung: %s", from, subject, label)
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{
Input: []string{text},
Model: openai.EmbeddingModel(config.Cfg.Embedding.Model),
})
if err != nil {
return fmt.Errorf("embedding: %w", err)
}
conn := config.NewQdrantConn()
defer conn.Close()
id := triageID(text)
wait := true
_, err = pb.NewPointsClient(conn).Upsert(ctx, &pb.UpsertPoints{
CollectionName: config.Cfg.Qdrant.Collection,
Points: []*pb.PointStruct{{
Id: &pb.PointId{
PointIdOptions: &pb.PointId_Uuid{Uuid: id},
},
Vectors: &pb.Vectors{
VectorsOptions: &pb.Vectors_Vector{
Vector: &pb.Vector{Data: embResp.Data[0].Embedding},
},
},
Payload: map[string]*pb.Value{
"text": {Kind: &pb.Value_StringValue{StringValue: text}},
"source": {Kind: &pb.Value_StringValue{StringValue: "email_triage"}},
"type": {Kind: &pb.Value_StringValue{StringValue: "email_triage"}},
},
}},
Wait: &wait,
})
if err != nil {
return fmt.Errorf("qdrant upsert: %w", err)
}
slog.Debug("[Triage] Entscheidung gespeichert", "betreff", subject, "wichtig", isImportant)
return nil
}
// SearchSimilar sucht ähnliche vergangene Triage-Entscheidungen in Qdrant.
// Gibt bis zu 3 Ergebnisse zurück (nur type=email_triage, Score ≥ 0.7).
func SearchSimilar(query string) []TriageResult {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{
Input: []string{query},
Model: openai.EmbeddingModel(config.Cfg.Embedding.Model),
})
if err != nil {
slog.Warn("[Triage] Embedding für RAG fehlgeschlagen", "fehler", err)
return nil
}
conn := config.NewQdrantConn()
defer conn.Close()
threshold := float32(0.7)
result, err := pb.NewPointsClient(conn).Search(ctx, &pb.SearchPoints{
CollectionName: config.Cfg.Qdrant.Collection,
Vector: embResp.Data[0].Embedding,
Limit: 3,
WithPayload: &pb.WithPayloadSelector{
SelectorOptions: &pb.WithPayloadSelector_Enable{Enable: true},
},
ScoreThreshold: &threshold,
Filter: &pb.Filter{
Must: []*pb.Condition{{
ConditionOneOf: &pb.Condition_Field{
Field: &pb.FieldCondition{
Key: "type",
Match: &pb.Match{
MatchValue: &pb.Match_Keyword{Keyword: "email_triage"},
},
},
},
}},
},
})
if err != nil {
slog.Warn("[Triage] RAG-Suche fehlgeschlagen", "fehler", err)
return nil
}
var results []TriageResult
for _, hit := range result.Result {
text := hit.Payload["text"].GetStringValue()
if text == "" {
continue
}
results = append(results, TriageResult{Text: text, Score: hit.Score})
}
return results
}
func triageID(text string) string {
hash := sha256.Sum256([]byte("email_triage:" + text))
return hex.EncodeToString(hash[:16])
}

View File

@@ -2,13 +2,15 @@
{
"id": "1773950110942000154",
"text": "Synology DSM Update durchfuehren",
"done": false,
"created_at": "2026-03-19T20:55:10.942001964+01:00"
"done": true,
"created_at": "2026-03-19T20:55:10.942001964+01:00",
"done_at": "2026-03-20T20:56:51.054077698+01:00"
},
{
"id": "1773950110942351936",
"text": "Zahnarzt Termin bestaetigen",
"done": false,
"created_at": "2026-03-19T20:55:10.942353012+01:00"
"done": true,
"created_at": "2026-03-19T20:55:10.942353012+01:00",
"done_at": "2026-03-20T20:58:27.785644744+01:00"
}
]