zwischenstand
This commit is contained in:
102
CLAUDE.md
102
CLAUDE.md
@@ -48,11 +48,13 @@ cmd/discord/main.go
|
|||||||
├── internal/agents/research/ → brain.AskQuery() + Konversationsverlauf
|
├── internal/agents/research/ → brain.AskQuery() + Konversationsverlauf
|
||||||
├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage()
|
├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage()
|
||||||
├── internal/agents/task/ → tasks.json (atomisches JSON, DueDate + Priority)
|
├── 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()
|
[Daemon-Goroutine] startDaemon()
|
||||||
├── Email-Check (alle N min) → #localagent Discord-Channel
|
├── IMAP IDLE (pro Account) → Echtzeit-Email-Benachrichtigung + LLM-Triage
|
||||||
└── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert
|
├── 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)
|
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/memory/` | Memory-Agent: Ingest + Chat-Speicherung |
|
||||||
| `internal/agents/task/` | Task-Agent: Aufgabenverwaltung (tasks.json) |
|
| `internal/agents/task/` | Task-Agent: Aufgabenverwaltung (tasks.json) |
|
||||||
| `internal/agents/tool/` | Tool-Dispatcher |
|
| `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
|
### Discord Commands
|
||||||
|
|
||||||
@@ -86,11 +90,21 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
|
|||||||
| `/asknobrain` | – | Direkt an LLM (kein RAG) |
|
| `/asknobrain` | – | Direkt an LLM (kein RAG) |
|
||||||
| `/memory store` | `@bot remember <text>` | Text speichern |
|
| `/memory store` | `@bot remember <text>` | Text speichern |
|
||||||
| `/memory ingest` | `@bot ingest` | Markdown neu einlesen |
|
| `/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 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 |
|
| `/task list/done/delete` | `@bot task <aktion>` | Aufgaben verwalten |
|
||||||
| `/email summary/unread/remind` | `@bot email <aktion>` | Email-Analyse |
|
| `/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` |
|
| `/remember` | – | Alias für `/memory store` |
|
||||||
| `/ingest` | – | Alias für `/memory ingest` |
|
| `/ingest` | – | Alias für `/memory ingest` |
|
||||||
|
| *(PDF-Anhang)* | `@bot` + PDF-Datei | PDF direkt per Discord importieren |
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
@@ -102,19 +116,91 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel)
|
|||||||
- **LLM-Fallback**: Email-Zusammenfassung zeigt Rohliste wenn LLM nicht erreichbar
|
- **LLM-Fallback**: Email-Zusammenfassung zeigt Rohliste wenn LLM nicht erreichbar
|
||||||
- **Daemon**: läuft als Goroutine im Discord-Bot-Prozess (`startDaemon()`)
|
- **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
|
- **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)
|
- **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)
|
- **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
|
- **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
|
```yaml
|
||||||
|
# Einzelner Email-Account (Legacy, abwärtskompatibel):
|
||||||
email:
|
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:
|
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
|
## Deployment
|
||||||
|
|||||||
383
README.md
383
README.md
@@ -1,43 +1,340 @@
|
|||||||
# my-brain-importer
|
# Brain-Bot
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Features
|
||||||
|
|
||||||
- Go 1.22+
|
- **Discord-Bot** mit Slash-Commands und @Mention
|
||||||
- LocalAI läuft auf `embedding.url` mit dem konfigurierten Embedding-Modell geladen
|
- **RAG** über Markdown-Notizen, Emails, URLs, PDFs und RSS-Artikel
|
||||||
- LocalAI läuft auf `chat.url` mit dem konfigurierten Chat-Modell geladen
|
- **Email-Management**: IMAP IDLE, Triage (wichtig/unwichtig via LLM), Archiv-Cleanup
|
||||||
- Qdrant läuft auf dem NAS (Port 6334 gRPC, Port 6333 Dashboard)
|
- **Task-Verwaltung** mit Fälligkeit und Priorität
|
||||||
|
- **Morgen-Briefing** täglich um 8:00 (Tasks + ungelesene Emails)
|
||||||
## Build
|
- **Core Memory**: persistente Nutzerfakten, automatisch in jeden LLM-Prompt eingebaut
|
||||||
|
- **RSS-Watcher**: automatisches Importieren von Feed-Artikeln
|
||||||
```bash
|
- **User-Permissions**: optionale Einschränkung auf bestimmte Discord-User-IDs
|
||||||
bash build.sh
|
|
||||||
```
|
---
|
||||||
|
|
||||||
Erzeugt `bin/ingest`, `bin/ingest.exe`, `bin/ask`, `bin/ask.exe`.
|
## Voraussetzungen
|
||||||
|
|
||||||
## Nutzung
|
| Dienst | Adresse | Zweck |
|
||||||
|
|--------|---------|-------|
|
||||||
```bash
|
| Qdrant | `192.168.1.4:6334` (gRPC) | Vektordatenbank |
|
||||||
# Markdown-Dateien aus brain_root importieren
|
| LocalAI | `192.168.1.118:8080` | Embeddings + Chat (OpenAI-kompatibel) |
|
||||||
./bin/ingest
|
| IMAP-Server | konfigurierbar | Email-Abruf (STARTTLS oder TLS) |
|
||||||
|
| Discord | Bot-Token | Primäres Interface |
|
||||||
# Alternatives Verzeichnis angeben
|
|
||||||
./bin/ingest /pfad/zum/verzeichnis
|
---
|
||||||
|
|
||||||
# Bildbeschreibungen aus JSON importieren
|
## Schnellstart
|
||||||
./bin/ingest image_descriptions.json
|
|
||||||
|
```bash
|
||||||
# Frage stellen
|
# Einmalig: config.yml anlegen
|
||||||
./bin/ask "Was sind meine Reisepläne für Norwegen?"
|
cp config.yml.example config.yml # Credentials eintragen
|
||||||
./bin/ask "Erzähl mir über Veronica Bellmore"
|
|
||||||
```
|
# Bot starten
|
||||||
|
go run ./cmd/discord/
|
||||||
## Brain aktualisieren
|
|
||||||
|
# Oder: CLI-Tools
|
||||||
Kein Löschen der Datenbank nötig — einfach `./bin/ingest` erneut ausführen:
|
go run ./cmd/ask/ "Was sind meine TODOs?"
|
||||||
- Bestehende Chunks → gleiche SHA256-ID → Qdrant überschreibt
|
go run ./cmd/ingest/ # Markdown aus brain_root importieren
|
||||||
- Neue Dateien → neue IDs → werden hinzugefügt
|
```
|
||||||
|
|
||||||
Architektur und Konfiguration: [doc/architecture.md](doc/architecture.md)
|
---
|
||||||
|
|
||||||
|
## 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.0–1.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 |
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
// discord – Discord-Bot für my-brain-importer
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -21,6 +26,7 @@ import (
|
|||||||
"my-brain-importer/internal/agents/task"
|
"my-brain-importer/internal/agents/task"
|
||||||
"my-brain-importer/internal/agents/tool"
|
"my-brain-importer/internal/agents/tool"
|
||||||
"my-brain-importer/internal/agents/tool/email"
|
"my-brain-importer/internal/agents/tool/email"
|
||||||
|
"my-brain-importer/internal/agents/tool/rss"
|
||||||
"my-brain-importer/internal/brain"
|
"my-brain-importer/internal/brain"
|
||||||
"my-brain-importer/internal/config"
|
"my-brain-importer/internal/config"
|
||||||
"my-brain-importer/internal/diag"
|
"my-brain-importer/internal/diag"
|
||||||
@@ -93,6 +99,46 @@ var (
|
|||||||
Name: "ingest",
|
Name: "ingest",
|
||||||
Description: "Markdown-Notizen aus brain_root importieren",
|
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",
|
Name: "remind",
|
||||||
Description: "Termine und Deadlines aus Emails extrahieren",
|
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
|
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() {
|
func main() {
|
||||||
config.LoadConfig()
|
config.LoadConfig()
|
||||||
|
patchEmailMoveChoices() // Dynamische Archivordner aus Config einsetzen
|
||||||
|
|
||||||
token := config.Cfg.Discord.Token
|
token := config.Cfg.Discord.Token
|
||||||
if token == "" || token == "dein-discord-bot-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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +449,20 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
|
|
||||||
case "email":
|
case "email":
|
||||||
handleEmailCommand(s, i)
|
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 {
|
handleAgentResponse(s, i, func() agents.Response {
|
||||||
return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest})
|
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) {
|
func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
sub := i.ApplicationCommandData().Options[0]
|
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 {
|
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) {
|
func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) {
|
||||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
@@ -400,6 +842,23 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
|||||||
return
|
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(
|
question := strings.TrimSpace(
|
||||||
strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""),
|
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())
|
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).
|
// SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung).
|
||||||
func SendMessage(channelID, text string) error {
|
func SendMessage(channelID, text string) error {
|
||||||
if dg == nil {
|
if dg == nil {
|
||||||
@@ -438,12 +928,18 @@ func routeMessage(text, author, channelID string) agents.Response {
|
|||||||
args := words[1:]
|
args := words[1:]
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
|
case "clear":
|
||||||
|
clearHistory(channelID)
|
||||||
|
return agents.Response{Text: "🗑️ Gesprächsverlauf gelöscht."}
|
||||||
|
|
||||||
case "email":
|
case "email":
|
||||||
sub := "summary"
|
sub := "summary"
|
||||||
|
emailArgs := []string{}
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
sub = strings.ToLower(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":
|
case "task":
|
||||||
action := "list"
|
action := "list"
|
||||||
@@ -507,28 +1003,24 @@ func sendWelcomeMessage() {
|
|||||||
|
|
||||||
**Slash-Commands:**
|
**Slash-Commands:**
|
||||||
` + "```" + `
|
` + "```" + `
|
||||||
/ask <frage> – Wissensdatenbank abfragen
|
/ask <frage> – Wissensdatenbank abfragen
|
||||||
/research <frage> – Alias für /ask
|
/research <frage> – Alias für /ask
|
||||||
/asknobrain <frage> – Direkt ans LLM (kein RAG)
|
/asknobrain <frage> – Direkt ans LLM (kein RAG)
|
||||||
/memory store <text> – Text in Wissensdatenbank speichern
|
/memory store <text> – Text speichern
|
||||||
/memory ingest – Markdown-Notizen neu einlesen
|
/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 add <text> [--due YYYY-MM-DD] [--priority hoch|mittel|niedrig]
|
||||||
/task list – Alle Tasks anzeigen
|
/task list / done / delete
|
||||||
/task done <id> – Task erledigen
|
/email summary / unread / remind / ingest / move / triage
|
||||||
/task delete <id> – Task löschen
|
/status – Bot-Status
|
||||||
/email summary – Letzte Emails zusammenfassen
|
/clear – Gesprächsverlauf zurücksetzen
|
||||||
/email unread – Ungelesene Emails zusammenfassen
|
|
||||||
/email remind – Termine aus Emails extrahieren
|
|
||||||
` + "```" + `
|
` + "```" + `
|
||||||
**@Mention:**
|
**@Mention:** PDF-Anhang schicken → automatisch importiert
|
||||||
` + "```" + `
|
⚙️ Daemon: IMAP IDLE + Morgen-Briefing + RSS-Feeds`
|
||||||
@Brain <frage> – Wissensdatenbank (mit Chat-Gedächtnis)
|
|
||||||
@Brain task add <text> [--due ...] [--priority ...]
|
|
||||||
@Brain task list / done / delete
|
|
||||||
@Brain email summary / unread / remind
|
|
||||||
@Brain remember <text>
|
|
||||||
` + "```" + `
|
|
||||||
⚙️ Daemon aktiv: Email-Check + tägliches Morgen-Briefing`
|
|
||||||
|
|
||||||
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
|
if _, err := dg.ChannelMessageSend(channelID, msg); err != nil {
|
||||||
log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err)
|
log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err)
|
||||||
@@ -545,7 +1037,73 @@ func getAuthor(i *discordgo.InteractionCreate) string {
|
|||||||
return "unknown"
|
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.
|
// 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() {
|
func startDaemon() {
|
||||||
channelID := config.Cfg.Daemon.ChannelID
|
channelID := config.Cfg.Daemon.ChannelID
|
||||||
if channelID == "" {
|
if channelID == "" {
|
||||||
@@ -553,53 +1111,121 @@ func startDaemon() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emailInterval := time.Duration(config.Cfg.Daemon.EmailIntervalMin) * time.Minute
|
|
||||||
if emailInterval == 0 {
|
|
||||||
emailInterval = 30 * time.Minute
|
|
||||||
}
|
|
||||||
reminderHour := config.Cfg.Daemon.TaskReminderHour
|
reminderHour := config.Cfg.Daemon.TaskReminderHour
|
||||||
if reminderHour == 0 {
|
if reminderHour == 0 {
|
||||||
reminderHour = 8
|
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)
|
// RSS-Watcher starten (wenn Feeds konfiguriert)
|
||||||
defer emailTicker.Stop()
|
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)
|
briefingTimer := scheduleDaily(reminderHour, 0)
|
||||||
defer briefingTimer.Stop()
|
defer briefingTimer.Stop()
|
||||||
|
cleanupTimer := scheduleDaily(cleanupHour, 0)
|
||||||
|
defer cleanupTimer.Stop()
|
||||||
|
ingestTimer := scheduleDaily(ingestHour, 0)
|
||||||
|
defer ingestTimer.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-daemonStop:
|
case <-daemonStop:
|
||||||
slog.Info("Daemon gestoppt")
|
slog.Info("Daemon gestoppt")
|
||||||
|
cancel()
|
||||||
return
|
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:
|
case <-briefingTimer.C:
|
||||||
slog.Info("Daemon: Morgen-Briefing gestartet")
|
slog.Info("Daemon: Morgen-Briefing gestartet")
|
||||||
dailyBriefing(channelID)
|
dailyBriefing(channelID)
|
||||||
briefingTimer.Stop()
|
briefingTimer.Stop()
|
||||||
briefingTimer = scheduleDaily(reminderHour, 0)
|
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.
|
// scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert.
|
||||||
func scheduleDaily(hour, minute int) *time.Timer {
|
func scheduleDaily(hour, minute int) *time.Timer {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -623,6 +1249,7 @@ func dailyBriefing(channelID string) {
|
|||||||
open, err := store.OpenTasks()
|
open, err := store.OpenTasks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Daemon Briefing Task-Fehler", "fehler", err)
|
slog.Error("Daemon Briefing Task-Fehler", "fehler", err)
|
||||||
|
open = nil // explizit nil, len(open)==0 → kein Task-Abschnitt
|
||||||
} else if len(open) > 0 {
|
} else if len(open) > 0 {
|
||||||
fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open))
|
fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open))
|
||||||
for _, t := range open {
|
for _, t := range open {
|
||||||
|
|||||||
@@ -1,60 +1,234 @@
|
|||||||
# Architektur
|
# Architektur
|
||||||
|
|
||||||
```
|
## Übersicht
|
||||||
AI_Brain/
|
|
||||||
*.md Dateien
|
```
|
||||||
│
|
Discord (primäres Interface)
|
||||||
▼
|
↓ Slash-Commands + @Mention + PDF-Anhänge
|
||||||
bin/ingest Embeddings via LocalAI
|
cmd/discord/main.go
|
||||||
│
|
├── Research-Agent → brain.AskQuery() + Konversationsverlauf pro Channel
|
||||||
▼
|
├── Memory-Agent → brain.RunIngest(), IngestChatMessage(), IngestURL(), CoreMemory
|
||||||
Qdrant (NAS) ◄──── bin/ask ──► LM Studio (Chat)
|
├── Task-Agent → tasks.json (atomares JSON, DueDate + Priority)
|
||||||
```
|
├── Tool-Agent → Dispatcher für Email-Aktionen
|
||||||
|
└── Daemon-Goroutinen:
|
||||||
- **Embeddings**: LocalAI unter `embedding.url` (Modell konfigurierbar)
|
├── IMAP IDLE (pro Account) → Echtzeit-Triage + Discord-Benachrichtigung
|
||||||
- **Vektordatenbank**: Qdrant auf dem NAS
|
├── RSS-Watcher → Artikel-Import in Qdrant
|
||||||
- **Chat-Completion**: LocalAI unter `chat.url` (Modell konfigurierbar)
|
├── Morgen-Briefing (08:00) → Tasks + Emails kombiniert
|
||||||
|
├── Archiv-Cleanup (02:00) → CleanupArchiveFolders() nach retention_days
|
||||||
## Projektstruktur
|
└── Nacht-Ingest (23:00) → brain.IngestEmailFolder() für alle Archive
|
||||||
|
↓
|
||||||
```
|
Qdrant (gRPC, 192.168.1.4:6334) LocalAI (HTTP, 192.168.1.118:8080)
|
||||||
AI-Agent/
|
Vektordatenbank Embedding-Modell + Chat-Modell
|
||||||
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
|
## Packages
|
||||||
brain/
|
|
||||||
ingest.go Markdown-Import, Chunking
|
### `cmd/discord/`
|
||||||
ingest_json.go JSON-Import (Bildbeschreibungen)
|
Primärer Einstiegspunkt. Registriert Discord Slash-Commands, verarbeitet Interaktionen und @Mentions, startet den Daemon.
|
||||||
ask.go Suche + LLM-Antwort
|
|
||||||
bin/ Kompilierte Binaries (von build.sh erzeugt)
|
**Wichtige Funktionen:**
|
||||||
config.yml Alle Einstellungen
|
- `main()` — Config laden, Discord verbinden, Commands registrieren, Daemon starten
|
||||||
build.sh Baut beide Binaries
|
- `onInteraction()` — Slash-Command-Handler mit Berechtigungsprüfung
|
||||||
```
|
- `onMessage()` — @Mention-Handler inkl. PDF-Anhang-Erkennung
|
||||||
|
- `routeMessage()` — Leitet @Mention-Text an passenden Agenten weiter
|
||||||
## Konfiguration
|
- `startDaemon()` — Startet IMAP IDLE, RSS-Watcher, tägliche Timer
|
||||||
|
- `dailyBriefing()` — Morgen-Briefing (Tasks + Emails)
|
||||||
Alle Einstellungen in `config.yml` (muss im Arbeitsverzeichnis liegen):
|
- `nightlyIngest()` — Archiv-Ordner in Qdrant importieren
|
||||||
|
- `patchEmailMoveChoices()` — Discord-Choices dynamisch aus Config befüllen
|
||||||
```yaml
|
- `isAllowed(userID)` — User-Berechtigungsprüfung
|
||||||
qdrant:
|
|
||||||
host: "192.168.1.4"
|
### `internal/config/`
|
||||||
port: "6334"
|
Konfigurationsstruktur (`Config`), Client-Factories und `AllEmailAccounts()`.
|
||||||
api_key: "..."
|
|
||||||
collection: "jacek-brain"
|
```go
|
||||||
|
type Config struct {
|
||||||
embedding:
|
Qdrant, Embedding, Chat // Externe Dienste
|
||||||
url: "http://192.168.1.118:8080/v1"
|
Discord // Token, GuildID, AllowedUsers
|
||||||
model: "qwen3-embedding-4b"
|
Email / EmailAccounts // IMAP (Legacy/Multi-Account)
|
||||||
dimensions: 2560 # muss zum Modell passen
|
Tasks // JSON-Pfad
|
||||||
|
Daemon // Timer-Uhrzeiten, Channel-ID
|
||||||
chat:
|
BrainRoot, TopK, ScoreThreshold
|
||||||
url: "http://192.168.1.118:8080/v1"
|
RSSFeeds // RSS-Feed-URLs + Intervalle
|
||||||
model: "qwen3.5-4b-claude-4.6-opus-reasoning-distilled"
|
}
|
||||||
|
```
|
||||||
brain_root: "/mnt/c/Users/jacek/AI_Brain"
|
|
||||||
top_k: 3
|
- `LoadConfig()` — liest `config.yml`, validiert Pflichtfelder
|
||||||
```
|
- `AllEmailAccounts()` — gibt alle Accounts zurück (Multi-Account-Vorrang über Legacy)
|
||||||
|
- `NewQdrantConn()`, `NewEmbeddingClient()`, `NewChatClient()` — Client-Factories
|
||||||
> **Wichtig:** Wenn du `embedding.model` oder `dimensions` änderst, muss die Qdrant-Collection neu erstellt werden (im Dashboard löschen, dann `ingest` erneut ausführen).
|
|
||||||
|
### `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 | 5–60s 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
10
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module my-brain-importer
|
module my-brain-importer
|
||||||
|
|
||||||
go 1.22.2
|
go 1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
@@ -12,9 +12,17 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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-message v0.18.2 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/gorilla/websocket v1.4.2 // 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/crypto v0.32.0 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/net v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
|||||||
26
go.sum
26
go.sum
@@ -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 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
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 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
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=
|
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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T4M=
|
||||||
github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw=
|
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 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
|
||||||
github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
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=
|
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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
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/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-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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
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/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-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-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-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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/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.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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
|||||||
@@ -15,9 +15,22 @@ const (
|
|||||||
ActionDone = "done"
|
ActionDone = "done"
|
||||||
ActionDelete = "delete"
|
ActionDelete = "delete"
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
ActionIngestURL = "url"
|
||||||
|
ActionIngestPDF = "pdf"
|
||||||
|
ActionProfile = "profile"
|
||||||
|
ActionProfileShow = "profile-show"
|
||||||
|
|
||||||
|
// Knowledge
|
||||||
|
ActionKnowledgeList = "list"
|
||||||
|
ActionKnowledgeDelete = "delete"
|
||||||
|
|
||||||
// Tool/Email
|
// Tool/Email
|
||||||
ActionEmail = "email"
|
ActionEmail = "email"
|
||||||
ActionEmailSummary = "summary"
|
ActionEmailSummary = "summary"
|
||||||
ActionEmailUnread = "unread"
|
ActionEmailUnread = "unread"
|
||||||
ActionEmailRemind = "remind"
|
ActionEmailRemind = "remind"
|
||||||
|
ActionEmailIngest = "ingest"
|
||||||
|
ActionEmailMove = "move"
|
||||||
|
ActionEmailTriage = "triage"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package tool
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"my-brain-importer/internal/agents"
|
"my-brain-importer/internal/agents"
|
||||||
"my-brain-importer/internal/agents/tool/email"
|
"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.
|
// 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()
|
result, err = email.SummarizeUnread()
|
||||||
case agents.ActionEmailRemind:
|
case agents.ActionEmailRemind:
|
||||||
result, err = email.ExtractReminders()
|
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:
|
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 {
|
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}
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ package email
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
imap "github.com/emersion/go-imap/v2"
|
imap "github.com/emersion/go-imap/v2"
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
@@ -18,15 +23,43 @@ type Message struct {
|
|||||||
Date string
|
Date string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client wraps die IMAP-Verbindung.
|
// SelectMessage koppelt eine Message mit ihrer IMAP-Sequenznummer für UI-Zwecke.
|
||||||
type Client struct {
|
type SelectMessage struct {
|
||||||
c *imapclient.Client
|
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) {
|
func Connect() (*Client, error) {
|
||||||
cfg := config.Cfg.Email
|
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 (
|
var (
|
||||||
c *imapclient.Client
|
c *imapclient.Client
|
||||||
@@ -34,11 +67,11 @@ func Connect() (*Client, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case cfg.TLS:
|
case acc.TLS:
|
||||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
tlsCfg := &tls.Config{ServerName: acc.Host}
|
||||||
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||||
case cfg.StartTLS:
|
case acc.StartTLS:
|
||||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
tlsCfg := &tls.Config{ServerName: acc.Host}
|
||||||
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||||
default:
|
default:
|
||||||
c, err = imapclient.DialInsecure(addr, nil)
|
c, err = imapclient.DialInsecure(addr, nil)
|
||||||
@@ -47,12 +80,12 @@ func Connect() (*Client, error) {
|
|||||||
return nil, fmt.Errorf("IMAP verbinden: %w", err)
|
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()
|
c.Close()
|
||||||
return nil, fmt.Errorf("IMAP login: %w", err)
|
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.
|
// Close schließt die Verbindung.
|
||||||
@@ -61,9 +94,28 @@ func (cl *Client) Close() {
|
|||||||
cl.c.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).
|
// FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body).
|
||||||
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
||||||
folder := config.Cfg.Email.Folder
|
folder := cl.folder
|
||||||
if folder == "" {
|
if folder == "" {
|
||||||
folder = "INBOX"
|
folder = "INBOX"
|
||||||
}
|
}
|
||||||
@@ -94,7 +146,7 @@ func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
|||||||
|
|
||||||
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
|
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
|
||||||
func (cl *Client) FetchUnread() ([]Message, error) {
|
func (cl *Client) FetchUnread() ([]Message, error) {
|
||||||
folder := config.Cfg.Email.Folder
|
folder := cl.folder
|
||||||
if folder == "" {
|
if folder == "" {
|
||||||
folder = "INBOX"
|
folder = "INBOX"
|
||||||
}
|
}
|
||||||
@@ -129,7 +181,7 @@ func (cl *Client) FetchUnread() ([]Message, error) {
|
|||||||
// FetchUnreadSeqNums holt ungelesene Emails und gibt zusätzlich die Sequenznummern zurück.
|
// FetchUnreadSeqNums holt ungelesene Emails und gibt zusätzlich die Sequenznummern zurück.
|
||||||
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
|
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
|
||||||
func (cl *Client) FetchUnreadSeqNums() ([]Message, []uint32, error) {
|
func (cl *Client) FetchUnreadSeqNums() ([]Message, []uint32, error) {
|
||||||
folder := config.Cfg.Email.Folder
|
folder := cl.folder
|
||||||
if folder == "" {
|
if folder == "" {
|
||||||
folder = "INBOX"
|
folder = "INBOX"
|
||||||
}
|
}
|
||||||
@@ -172,6 +224,328 @@ func (cl *Client) MoveMessages(seqNums []uint32, destFolder string) error {
|
|||||||
return nil
|
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 {
|
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
|
||||||
result := make([]Message, 0, len(msgs))
|
result := make([]Message, 0, len(msgs))
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
|
|||||||
153
internal/agents/tool/email/idle.go
Normal file
153
internal/agents/tool/email/idle.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -11,60 +11,510 @@ import (
|
|||||||
openai "github.com/sashabaranov/go-openai"
|
openai "github.com/sashabaranov/go-openai"
|
||||||
|
|
||||||
"my-brain-importer/internal/config"
|
"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.
|
// Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen.
|
||||||
func Summarize() (string, error) {
|
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.
|
// SummarizeUnread fasst ungelesene Emails für alle konfigurierten Accounts zusammen.
|
||||||
// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben.
|
// Wenn email.processed_folder konfiguriert ist, werden Emails danach dorthin verschoben.
|
||||||
func SummarizeUnread() (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
return "", fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
|
||||||
}
|
}
|
||||||
defer cl.Close()
|
defer cl.Close()
|
||||||
|
|
||||||
processedFolder := config.Cfg.Email.ProcessedFolder
|
|
||||||
|
|
||||||
var msgs []Message
|
var msgs []Message
|
||||||
var seqNums []uint32
|
var seqNums []uint32
|
||||||
|
|
||||||
if processedFolder != "" {
|
if acc.ProcessedFolder != "" {
|
||||||
msgs, seqNums, err = cl.FetchUnreadSeqNums()
|
msgs, seqNums, err = cl.FetchUnreadSeqNums()
|
||||||
} else {
|
} else {
|
||||||
msgs, err = cl.FetchUnread()
|
msgs, err = cl.FetchUnread()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Emails abrufen: %w", err)
|
return "", fmt.Errorf("Emails abrufen (%s): %w", accountLabel(acc), err)
|
||||||
}
|
}
|
||||||
if len(msgs) == 0 {
|
if len(msgs) == 0 {
|
||||||
return "📭 Keine ungelesenen Emails.", nil
|
return "📭 Keine ungelesenen Emails.", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread")
|
slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs), "typ", "unread")
|
||||||
result, err := summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
|
result, err := summarizeWithLLMModel(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.", accountModel(acc))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben
|
// Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben
|
||||||
if processedFolder != "" && len(seqNums) > 0 {
|
if acc.ProcessedFolder != "" && len(seqNums) > 0 {
|
||||||
if moveErr := cl.MoveMessages(seqNums, processedFolder); moveErr != nil {
|
if moveErr := cl.MoveMessages(seqNums, acc.ProcessedFolder); moveErr != nil {
|
||||||
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", processedFolder)
|
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", acc.ProcessedFolder)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", processedFolder)
|
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", acc.ProcessedFolder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
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.
|
// ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines.
|
||||||
func ExtractReminders() (string, error) {
|
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).
|
// 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)
|
return summarizeWithLLM(msgs, instruction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAndSummarize(n uint32, instruction string) (string, error) {
|
func fetchAndSummarizeAccount(acc config.EmailAccount, n uint32, instruction string) (string, error) {
|
||||||
cl, err := Connect()
|
cl, err := ConnectAccount(acc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||||||
}
|
}
|
||||||
@@ -87,12 +537,27 @@ func fetchAndSummarize(n uint32, instruction string) (string, error) {
|
|||||||
return "📭 Keine Emails gefunden.", nil
|
return "📭 Keine Emails gefunden.", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs))
|
slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
|
||||||
return summarizeWithLLM(msgs, instruction)
|
return summarizeWithLLMModel(msgs, instruction, accountModel(acc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück.
|
// accountLabel gibt einen lesbaren Namen für einen Account zurück.
|
||||||
// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist.
|
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 {
|
func emailModel() string {
|
||||||
if config.Cfg.Email.Model != "" {
|
if config.Cfg.Email.Model != "" {
|
||||||
return 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) {
|
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)
|
emailList := formatEmailList(msgs)
|
||||||
model := emailModel()
|
|
||||||
|
|
||||||
chatClient := config.NewChatClient()
|
chatClient := config.NewChatClient()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"my-brain-importer/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testMessages = []Message{
|
var testMessages = []Message{
|
||||||
@@ -70,3 +72,46 @@ func TestMessage_DateFormat(t *testing.T) {
|
|||||||
t.Errorf("Datumsformat ungültig: %v", err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
168
internal/agents/tool/rss/watcher.go
Normal file
168
internal/agents/tool/rss/watcher.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,8 @@ func AskQuery(question string, history []agents.HistoryMessage) (string, []Knowl
|
|||||||
|
|
||||||
contextText := buildContext(chunks)
|
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.
|
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:".
|
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
|
- Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es deutlich
|
||||||
- Antworte auf Deutsch
|
- Antworte auf Deutsch
|
||||||
- Sei präzise und direkt`
|
- 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:
|
userPrompt := fmt.Sprintf(`Hier sind die relevanten Informationen aus meiner Wissensdatenbank:
|
||||||
|
|
||||||
|
|||||||
54
internal/brain/core_memory.go
Normal file
54
internal/brain/core_memory.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -256,3 +256,30 @@ func IngestChatMessage(text, author, source string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
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)
|
||||||
|
}
|
||||||
|
|||||||
98
internal/brain/ingest_email.go
Normal file
98
internal/brain/ingest_email.go
Normal 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()
|
||||||
|
}
|
||||||
82
internal/brain/ingest_pdf.go
Normal file
82
internal/brain/ingest_pdf.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/brain/ingest_url.go
Normal file
124
internal/brain/ingest_url.go
Normal 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
108
internal/brain/knowledge.go
Normal 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 }
|
||||||
@@ -12,6 +12,30 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"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 {
|
type Config struct {
|
||||||
Qdrant struct {
|
Qdrant struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
@@ -32,22 +56,30 @@ type Config struct {
|
|||||||
} `yaml:"chat"`
|
} `yaml:"chat"`
|
||||||
|
|
||||||
Discord struct {
|
Discord struct {
|
||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
GuildID string `yaml:"guild_id"`
|
GuildID string `yaml:"guild_id"`
|
||||||
|
AllowedUsers []string `yaml:"allowed_users"` // Wenn gesetzt, dürfen nur diese User-IDs den Bot nutzen
|
||||||
} `yaml:"discord"`
|
} `yaml:"discord"`
|
||||||
|
|
||||||
|
// Email ist der Legacy-Block für einen einzelnen Account.
|
||||||
Email struct {
|
Email struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
TLS bool `yaml:"tls"`
|
TLS bool `yaml:"tls"`
|
||||||
StartTLS bool `yaml:"starttls"`
|
StartTLS bool `yaml:"starttls"`
|
||||||
Folder string `yaml:"folder"`
|
Folder string `yaml:"folder"`
|
||||||
ProcessedFolder string `yaml:"processed_folder"` // Zielordner nach Zusammenfassung (leer = kein Verschieben)
|
ProcessedFolder string `yaml:"processed_folder"`
|
||||||
Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen
|
Model string `yaml:"model"`
|
||||||
|
ArchiveFolders []ArchiveFolder `yaml:"archive_folders"`
|
||||||
|
TriageImportantFolder string `yaml:"triage_important_folder"`
|
||||||
|
TriageUnimportantFolder string `yaml:"triage_unimportant_folder"`
|
||||||
} `yaml:"email"`
|
} `yaml:"email"`
|
||||||
|
|
||||||
|
// EmailAccounts ermöglicht mehrere IMAP-Accounts. Hat Vorrang vor email:.
|
||||||
|
EmailAccounts []EmailAccount `yaml:"email_accounts"`
|
||||||
|
|
||||||
Tasks struct {
|
Tasks struct {
|
||||||
StorePath string `yaml:"store_path"`
|
StorePath string `yaml:"store_path"`
|
||||||
} `yaml:"tasks"`
|
} `yaml:"tasks"`
|
||||||
@@ -56,15 +88,52 @@ type Config struct {
|
|||||||
ChannelID string `yaml:"channel_id"`
|
ChannelID string `yaml:"channel_id"`
|
||||||
EmailIntervalMin int `yaml:"email_interval_min"`
|
EmailIntervalMin int `yaml:"email_interval_min"`
|
||||||
TaskReminderHour int `yaml:"task_reminder_hour"`
|
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"`
|
} `yaml:"daemon"`
|
||||||
|
|
||||||
BrainRoot string `yaml:"brain_root"`
|
BrainRoot string `yaml:"brain_root"`
|
||||||
TopK uint64 `yaml:"top_k"`
|
TopK uint64 `yaml:"top_k"`
|
||||||
ScoreThreshold float32 `yaml:"score_threshold"`
|
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
|
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.
|
// NewQdrantConn öffnet eine gRPC-Verbindung zur Qdrant-Instanz.
|
||||||
// Der Aufrufer ist verantwortlich für conn.Close().
|
// Der Aufrufer ist verantwortlich für conn.Close().
|
||||||
func NewQdrantConn() *grpc.ClientConn {
|
func NewQdrantConn() *grpc.ClientConn {
|
||||||
|
|||||||
87
internal/config/config_test.go
Normal file
87
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,11 +49,18 @@ func RunAll() (results []Result, allOK bool) {
|
|||||||
check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg)
|
check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMAP
|
// IMAP – alle konfigurierten Accounts prüfen
|
||||||
if cfg.Email.Host != "" {
|
for _, acc := range config.AllEmailAccounts() {
|
||||||
imapAddr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
|
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)
|
ok, msg = tcpCheck(imapAddr)
|
||||||
check("IMAP ("+imapAddr+")", ok, msg)
|
check("IMAP "+label+" ("+imapAddr+")", ok, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, allOK
|
return results, allOK
|
||||||
|
|||||||
138
internal/triage/triage.go
Normal file
138
internal/triage/triage.go
Normal 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])
|
||||||
|
}
|
||||||
10
tasks.json
10
tasks.json
@@ -2,13 +2,15 @@
|
|||||||
{
|
{
|
||||||
"id": "1773950110942000154",
|
"id": "1773950110942000154",
|
||||||
"text": "Synology DSM Update durchfuehren",
|
"text": "Synology DSM Update durchfuehren",
|
||||||
"done": false,
|
"done": true,
|
||||||
"created_at": "2026-03-19T20:55:10.942001964+01:00"
|
"created_at": "2026-03-19T20:55:10.942001964+01:00",
|
||||||
|
"done_at": "2026-03-20T20:56:51.054077698+01:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1773950110942351936",
|
"id": "1773950110942351936",
|
||||||
"text": "Zahnarzt Termin bestaetigen",
|
"text": "Zahnarzt Termin bestaetigen",
|
||||||
"done": false,
|
"done": true,
|
||||||
"created_at": "2026-03-19T20:55:10.942353012+01:00"
|
"created_at": "2026-03-19T20:55:10.942353012+01:00",
|
||||||
|
"done_at": "2026-03-20T20:58:27.785644744+01:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in New Issue
Block a user