From 905981cd1e25dedf7c94dd4a6ad79b91b97ea592 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Fri, 20 Mar 2026 23:24:56 +0100 Subject: [PATCH] zwischenstand --- CLAUDE.md | 102 ++- README.md | 383 +++++++++-- cmd/discord/main.go | 719 +++++++++++++++++++-- doc/architecture.md | 294 +++++++-- go.mod | 10 +- go.sum | 26 + internal/agents/actions.go | 15 +- internal/agents/tool/agent.go | 152 ++++- internal/agents/tool/email/client.go | 402 +++++++++++- internal/agents/tool/email/idle.go | 153 +++++ internal/agents/tool/email/summary.go | 514 ++++++++++++++- internal/agents/tool/email/summary_test.go | 45 ++ internal/agents/tool/rss/watcher.go | 168 +++++ internal/brain/ask.go | 7 +- internal/brain/core_memory.go | 54 ++ internal/brain/ingest.go | 27 + internal/brain/ingest_email.go | 98 +++ internal/brain/ingest_pdf.go | 82 +++ internal/brain/ingest_url.go | 124 ++++ internal/brain/knowledge.go | 108 ++++ internal/config/config.go | 91 ++- internal/config/config_test.go | 87 +++ internal/diag/diag.go | 15 +- internal/triage/triage.go | 138 ++++ tasks.json | 10 +- 25 files changed, 3607 insertions(+), 217 deletions(-) create mode 100644 internal/agents/tool/email/idle.go create mode 100644 internal/agents/tool/rss/watcher.go create mode 100644 internal/brain/core_memory.go create mode 100644 internal/brain/ingest_email.go create mode 100644 internal/brain/ingest_pdf.go create mode 100644 internal/brain/ingest_url.go create mode 100644 internal/brain/knowledge.go create mode 100644 internal/config/config_test.go create mode 100644 internal/triage/triage.go diff --git a/CLAUDE.md b/CLAUDE.md index 761ce9f..cd9ad4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,11 +48,13 @@ cmd/discord/main.go ├── internal/agents/research/ → brain.AskQuery() + Konversationsverlauf ├── internal/agents/memory/ → brain.RunIngest(), brain.IngestChatMessage() ├── internal/agents/task/ → tasks.json (atomisches JSON, DueDate + Priority) - └── internal/agents/tool/email/ → IMAP + LLM-Zusammenfassung + Move to Processed + └── internal/agents/tool/email/ → IMAP + LLM-Triage + Zusammenfassung + Move to Archive + Cleanup ↓ [Daemon-Goroutine] startDaemon() - ├── Email-Check (alle N min) → #localagent Discord-Channel - └── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert + ├── IMAP IDLE (pro Account) → Echtzeit-Email-Benachrichtigung + LLM-Triage + ├── Morgen-Briefing (täglich 8h) → Tasks + Emails kombiniert + ├── Archiv-Aufräumen (täglich 2h) → CleanupArchiveFolders() nach retention_days + └── Nacht-Ingest (täglich 23h) → brain.IngestEmailFolder() für alle Archiv-Ordner cmd/ingest/ + cmd/ask/ (CLI-Tools, direkt nutzbar) ↓ @@ -76,7 +78,9 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) | `internal/agents/memory/` | Memory-Agent: Ingest + Chat-Speicherung | | `internal/agents/task/` | Task-Agent: Aufgabenverwaltung (tasks.json) | | `internal/agents/tool/` | Tool-Dispatcher | -| `internal/agents/tool/email/` | IMAP-Client + LLM-Email-Analyse + Move to Processed | +| `internal/agents/tool/email/` | IMAP-Client + LLM-Triage + Email-Analyse + IDLE-Watcher + Move to Archive + CleanupOldEmails | +| `internal/agents/tool/rss/` | RSS-Feed-Watcher: Feeds fetchen, Artikel in Qdrant importieren, Daemon-Integration | +| `internal/triage/` | RAG-basiertes Lernen: Triage-Entscheidungen in Qdrant speichern + suchen (eigenes Package um Import-Zyklus brain↔email zu vermeiden) | ### Discord Commands @@ -86,11 +90,21 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) | `/asknobrain` | – | Direkt an LLM (kein RAG) | | `/memory store` | `@bot remember ` | Text speichern | | `/memory ingest` | `@bot ingest` | Markdown neu einlesen | +| `/memory url ` | – | URL-Inhalt in Wissensdatenbank importieren | +| `/memory profile ` | – | 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 ` | – | Quelle aus Wissensdatenbank löschen | | `/task add [faellig] [prioritaet]` | `@bot task add [--due YYYY-MM-DD] [--priority hoch]` | Task hinzufügen | | `/task list/done/delete` | `@bot task ` | Aufgaben verwalten | | `/email summary/unread/remind` | `@bot email ` | Email-Analyse | +| `/email ingest [ordner]` | `@bot email ingest [ordner]` | Emails aus IMAP-Ordner in Wissensdatenbank importieren (Standard: Archiv) | +| `/email move ` | `@bot email move ` | Ungelesene Emails in Archivordner verschieben (Choices dynamisch aus `archive_folders`) | +| `/status` | – | Bot-Gesundheit: alle Dienste + offene Tasks | +| `/clear` | `@bot clear` | Gesprächsverlauf für diesen Channel löschen | | `/remember` | – | Alias für `/memory store` | | `/ingest` | – | Alias für `/memory ingest` | +| *(PDF-Anhang)* | `@bot` + PDF-Datei | PDF direkt per Discord importieren | ## Key Patterns @@ -102,19 +116,91 @@ Qdrant (gRPC) + LocalAI (HTTP, OpenAI-kompatibel) - **LLM-Fallback**: Email-Zusammenfassung zeigt Rohliste wenn LLM nicht erreichbar - **Daemon**: läuft als Goroutine im Discord-Bot-Prozess (`startDaemon()`) - **config.Cfg**: globale Variable — bei Tests muss `config.LoadConfig()` aufgerufen oder Cfg direkt gesetzt werden -- **Konversationsverlauf**: Pro Discord-Channel werden die letzten 10 Frage-Antwort-Paare in-memory gehalten und als History an `brain.AskQuery()` übergeben +- **Konversationsverlauf**: Pro Discord-Channel werden die letzten 10 Frage-Antwort-Paare in-memory gehalten und als History an `brain.AskQuery()` übergeben — Reset via `/clear` oder `@bot clear` - **Task-Felder**: `DueDate *time.Time` und `Priority string` (hoch/mittel/niedrig) — rückwärtskompatibel (omitempty) - **Email processed_folder**: Nach Zusammenfassung werden ungelesene Emails in konfigurierten IMAP-Ordner verschoben (leer = deaktiviert) - **Morgen-Briefing**: `dailyBriefing()` kombiniert offene Tasks (mit Fälligkeits-Highlighting) + ungelesene Emails täglich um 8:00 +- **Archiv-Cleanup**: `email.CleanupArchiveFolders()` läuft täglich um `cleanup_hour` (Standard: 2:00) — iteriert alle Accounts/`archive_folders`, löscht Emails älter als `retention_days` via IMAP `\Deleted` + `EXPUNGE`. `retention_days: 0` = dauerhaft behalten (No-op). +- **Email-Triage**: `email.triageUnread()` klassifiziert ungelesene Emails sequentiell (eine nach der anderen) als wichtig/unwichtig via LLM. Unwichtige Emails werden in `triage_folder` verschoben. Jede Entscheidung wird in Qdrant gespeichert (`type: email_triage`). Bei nächster Klassifizierung sucht `triage.SearchSimilar()` ähnliche Entscheidungen (Score ≥ 0.7) als Few-Shot-Kontext — das Modell lernt aus der Geschichte. Triage läuft vor `SummarizeUnreadAccount()`. +- **Nacht-Ingest**: `nightlyIngest()` läuft täglich um `ingest_hour` (Standard: 23:00) — importiert alle Emails aller Archiv-Ordner in Qdrant via `brain.IngestEmailFolder()`. +- **User-Permissions**: `discord.allowed_users: ["user-id1", "user-id2"]` — wenn gesetzt, dürfen nur diese Discord-User-IDs den Bot nutzen. Leer = keine Einschränkung. +- **URL-Ingest**: `brain.IngestURL(url)` — fetcht URL, extrahiert sichtbaren HTML-Text (skippt script/style/nav/footer), chunked und importiert in Qdrant. Via `/memory url `. +- **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 `. +- **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 ` 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 `. +- **IMAP IDLE**: `email.IdleWatcher` pro Account — Echtzeit-Benachrichtigung bei neuen Emails, kein Polling mehr. Race-sichere Implementierung mit `atomic.Uint32` für `numMsgs`. Automatischer Reconnect nach 60s bei Fehler. +- **Mehrere Email-Accounts**: `config.AllEmailAccounts()` gibt alle Accounts zurück — zuerst `email_accounts:` (Liste), Fallback auf Legacy `email:` Block. Alle Email-Funktionen iterieren über alle Accounts. +- **`/status`**: Ruft `diag.RunAll()` auf + Task-Zähler — zeigt Echtzeit-Status aller externen Dienste -## config.yml – Neue Felder +## config.yml – Alle Felder ```yaml +# Einzelner Email-Account (Legacy, abwärtskompatibel): email: - processed_folder: "Processed" # Zielordner nach Zusammenfassung (leer = kein Verschieben) + host: imap.strato.de + port: 143 + user: user@example.de + password: "..." + starttls: true # oder tls: true für implizites TLS (Port 993) + folder: INBOX # optional, Standard: INBOX + processed_folder: "Processed" # nach Zusammenfassung verschieben (leer = deaktiviert) + triage_folder: "Unwichtig" # LLM-Triage: unwichtige Emails hier ablegen (leer = deaktiviert) + model: "" # optional: eigenes LLM-Modell für Email-Analyse + archive_folders: # optional: Archivordner mit automatischer Bereinigung + - name: "Archiv" + imap_folder: "Archiv" + retention_days: 0 # 0 = dauerhaft behalten (kein Cleanup) + - name: "5Jahre" + imap_folder: "5Jahre" + retention_days: 1825 + - name: "2Jahre" + imap_folder: "2Jahre" + retention_days: 730 + +# Mehrere Email-Accounts (hat Vorrang vor email:): +email_accounts: + - name: "Privat" + host: imap.strato.de + port: 143 + starttls: true + user: privat@example.de + password: "..." + processed_folder: "Processed" + archive_folders: # optional, wie im email:-Block oben + - name: "Archiv" + imap_folder: "Archiv" + retention_days: 0 + - name: "Arbeit" + host: imap.firma.de + port: 993 + tls: true + user: jacek@firma.de + password: "..." daemon: - task_reminder_hour: 8 # Uhrzeit des Morgen-Briefings (Standard: 8) + channel_id: "123456789" # Discord-Channel für Daemon-Nachrichten + email_interval_min: 30 # (veraltet, IDLE ersetzt Polling) + task_reminder_hour: 8 # Uhrzeit des Morgen-Briefings (Standard: 8) + cleanup_hour: 2 # Uhrzeit des täglichen Archiv-Aufräumens (Standard: 2) + ingest_hour: 23 # Uhrzeit des nächtlichen Email-Ingests (Standard: 23) + +# Discord User-Permissions (optional): +discord: + token: "..." + guild_id: "..." # optional: nur für diese Guild registrieren + allowed_users: # optional: leer = alle dürfen den Bot nutzen + - "123456789" # Discord User-ID + +# RSS-Feeds (optional): +rss_feeds: + - url: "https://example.com/feed.xml" + interval_hours: 24 # Polling-Intervall (Standard: 24h) + - url: "https://news.example.de/rss" + interval_hours: 6 ``` ## Deployment diff --git a/README.md b/README.md index c6b0824..3f9545d 100755 --- a/README.md +++ b/README.md @@ -1,43 +1,340 @@ -# my-brain-importer - -Persönlicher Wissens-Agent für den AI_Brain. Importiert Markdown-Notizen und Bildbeschreibungen in eine Qdrant-Vektordatenbank und beantwortet Fragen darüber mit einem lokalen LLM. - -## Voraussetzungen - -- Go 1.22+ -- LocalAI läuft auf `embedding.url` mit dem konfigurierten Embedding-Modell geladen -- LocalAI läuft auf `chat.url` mit dem konfigurierten Chat-Modell geladen -- Qdrant läuft auf dem NAS (Port 6334 gRPC, Port 6333 Dashboard) - -## Build - -```bash -bash build.sh -``` - -Erzeugt `bin/ingest`, `bin/ingest.exe`, `bin/ask`, `bin/ask.exe`. - -## Nutzung - -```bash -# Markdown-Dateien aus brain_root importieren -./bin/ingest - -# Alternatives Verzeichnis angeben -./bin/ingest /pfad/zum/verzeichnis - -# Bildbeschreibungen aus JSON importieren -./bin/ingest image_descriptions.json - -# Frage stellen -./bin/ask "Was sind meine Reisepläne für Norwegen?" -./bin/ask "Erzähl mir über Veronica Bellmore" -``` - -## Brain aktualisieren - -Kein Löschen der Datenbank nötig — einfach `./bin/ingest` erneut ausführen: -- Bestehende Chunks → gleiche SHA256-ID → Qdrant überschreibt -- Neue Dateien → neue IDs → werden hinzugefügt - -Architektur und Konfiguration: [doc/architecture.md](doc/architecture.md) +# Brain-Bot + +Persönlicher KI-Assistent und RAG-System in Go. Speichert Wissen in einer Qdrant-Vektordatenbank und beantwortet Fragen über ein lokales LLM. Primäres Interface: Discord-Bot. Läuft als systemd-Dienst auf einem Home-Server. + +## Features + +- **Discord-Bot** mit Slash-Commands und @Mention +- **RAG** über Markdown-Notizen, Emails, URLs, PDFs und RSS-Artikel +- **Email-Management**: IMAP IDLE, Triage (wichtig/unwichtig via LLM), Archiv-Cleanup +- **Task-Verwaltung** mit Fälligkeit und Priorität +- **Morgen-Briefing** täglich um 8:00 (Tasks + ungelesene Emails) +- **Core Memory**: persistente Nutzerfakten, automatisch in jeden LLM-Prompt eingebaut +- **RSS-Watcher**: automatisches Importieren von Feed-Artikeln +- **User-Permissions**: optionale Einschränkung auf bestimmte Discord-User-IDs + +--- + +## Voraussetzungen + +| Dienst | Adresse | Zweck | +|--------|---------|-------| +| Qdrant | `192.168.1.4:6334` (gRPC) | Vektordatenbank | +| LocalAI | `192.168.1.118:8080` | Embeddings + Chat (OpenAI-kompatibel) | +| IMAP-Server | konfigurierbar | Email-Abruf (STARTTLS oder TLS) | +| Discord | Bot-Token | Primäres Interface | + +--- + +## Schnellstart + +```bash +# Einmalig: config.yml anlegen +cp config.yml.example config.yml # Credentials eintragen + +# Bot starten +go run ./cmd/discord/ + +# Oder: CLI-Tools +go run ./cmd/ask/ "Was sind meine TODOs?" +go run ./cmd/ingest/ # Markdown aus brain_root importieren +``` + +--- + +## Discord-Commands + +### Wissen abfragen + +| Command | Beschreibung | +|---------|-------------| +| `/ask ` | Wissensdatenbank abfragen (mit Gesprächsgedächtnis) | +| `/research ` | Alias für `/ask` | +| `/asknobrain ` | Direkt ans LLM, kein RAG | +| `/clear` | Gesprächsverlauf dieses Channels löschen | + +### Wissen speichern + +| Command | Beschreibung | +|---------|-------------| +| `/memory store ` | Text direkt in Wissensdatenbank speichern | +| `/memory ingest` | Alle Markdown-Dateien aus `brain_root` importieren | +| `/memory url ` | Webseite fetchen und importieren | +| `/memory profile ` | Fakt zum Kerngedächtnis hinzufügen | +| `/memory profile-show` | Kerngedächtnis anzeigen | +| `/remember ` | 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 ` | Quelle und alle ihre Chunks löschen | + +### Tasks + +| Command | Beschreibung | +|---------|-------------| +| `/task add [--due YYYY-MM-DD] [--priority hoch\|mittel\|niedrig]` | Task anlegen | +| `/task list` | Alle offenen Tasks anzeigen | +| `/task done ` | Task als erledigt markieren | +| `/task delete ` | 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 ` | 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 +@Brain task add [--due YYYY-MM-DD] [--priority hoch] +@Brain task list / done / delete +@Brain email summary / unread / remind / ingest [ordner] / move +@Brain remember +@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 | diff --git a/cmd/discord/main.go b/cmd/discord/main.go index a8dd459..3dbea29 100644 --- a/cmd/discord/main.go +++ b/cmd/discord/main.go @@ -1,13 +1,18 @@ // discord – Discord-Bot für my-brain-importer -// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember und @Mention +// Unterstützt /ask, /research, /memory, /task, /email, /ingest, /remember, /status, /clear und @Mention package main import ( + "context" "fmt" + "io" "log" "log/slog" + "net/http" "os" "os/signal" + "path/filepath" + "strconv" "strings" "sync" "syscall" @@ -21,6 +26,7 @@ import ( "my-brain-importer/internal/agents/task" "my-brain-importer/internal/agents/tool" "my-brain-importer/internal/agents/tool/email" + "my-brain-importer/internal/agents/tool/rss" "my-brain-importer/internal/brain" "my-brain-importer/internal/config" "my-brain-importer/internal/diag" @@ -93,6 +99,46 @@ var ( Name: "ingest", Description: "Markdown-Notizen aus brain_root importieren", }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "url", + Description: "URL-Inhalt in die Wissensdatenbank importieren", + Options: []*discordgo.ApplicationCommandOption{ + {Type: discordgo.ApplicationCommandOptionString, Name: "url", Description: "Die URL", Required: true}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "profile", + Description: "Fakt zum Kerngedächtnis hinzufügen", + Options: []*discordgo.ApplicationCommandOption{ + {Type: discordgo.ApplicationCommandOptionString, Name: "text", Description: "Der Fakt", Required: true}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "profile-show", + Description: "Kerngedächtnis anzeigen", + }, + }, + }, + { + Name: "knowledge", + Description: "Wissensdatenbank verwalten", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "list", + Description: "Gespeicherte Quellen auflisten", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "delete", + Description: "Quelle aus der Wissensdatenbank löschen", + Options: []*discordgo.ApplicationCommandOption{ + {Type: discordgo.ApplicationCommandOptionString, Name: "source", Description: "Quellenname (aus /knowledge list)", Required: true}, + }, + }, }, }, { @@ -151,8 +197,55 @@ var ( Name: "remind", Description: "Termine und Deadlines aus Emails extrahieren", }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "ingest", + Description: "Emails aus IMAP-Ordner in Wissensdatenbank importieren", + Options: []*discordgo.ApplicationCommandOption{ + {Type: discordgo.ApplicationCommandOptionString, Name: "ordner", Description: "IMAP-Ordner (Standard: Archiv)", Required: false}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "triage", + Description: "Letzte 10 Emails klassifizieren und in Wichtig/Unwichtig verschieben", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "move", + Description: "Emails in Archivordner verschieben", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "ordner", + Description: "Zielordner", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Archiv (dauerhaft)", Value: "Archiv"}, + {Name: "5Jahre (~5 Jahre)", Value: "5Jahre"}, + {Name: "2Jahre (~2 Jahre)", Value: "2Jahre"}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "alter", + Description: "Alle Emails älter als N Tage verschieben (kein Auswahlmenü)", + Required: false, + MinValue: floatPtr(1), + MaxValue: float64(3650), + }, + }, + }, }, }, + { + Name: "status", + Description: "Bot-Status: Verbindungen prüfen, offene Tasks zählen", + }, + { + Name: "clear", + Description: "Gesprächsverlauf für diesen Channel zurücksetzen", + }, } ) @@ -179,8 +272,16 @@ func addToHistory(channelID, role, content string) { historyCache[channelID] = msgs } +// clearHistory löscht den Gesprächsverlauf für einen Channel. +func clearHistory(channelID string) { + historyMu.Lock() + defer historyMu.Unlock() + delete(historyCache, channelID) +} + func main() { config.LoadConfig() + patchEmailMoveChoices() // Dynamische Archivordner aus Config einsetzen token := config.Cfg.Discord.Token if token == "" || token == "dein-discord-bot-token" { @@ -253,8 +354,55 @@ func registerCommands() { } } +// isAllowed prüft ob ein Discord-User den Bot nutzen darf. +// Wenn keine allowed_users konfiguriert sind, ist jeder erlaubt. +func isAllowed(userID string) bool { + if len(config.Cfg.Discord.AllowedUsers) == 0 { + return true + } + for _, id := range config.Cfg.Discord.AllowedUsers { + if id == userID { + return true + } + } + return false +} + +// getUserID extrahiert die User-ID aus einer Interaktion. +func getUserID(i *discordgo.InteractionCreate) string { + if i.Member != nil { + return i.Member.User.ID + } + if i.User != nil { + return i.User.ID + } + return "" +} + func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { - if i.Type != discordgo.InteractionApplicationCommand { + // Berechtigungsprüfung + if !isAllowed(getUserID(i)) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + switch i.Type { + case discordgo.InteractionMessageComponent: + data := i.MessageComponentData() + slog.Info("Komponente", "customID", data.CustomID, "user", getAuthor(i)) + if strings.HasPrefix(data.CustomID, "email_move:") { + handleEmailMoveSelect(s, i) + } + return + case discordgo.InteractionApplicationCommand: + // handled below + default: return } @@ -301,6 +449,20 @@ func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { case "email": handleEmailCommand(s, i) + + case "knowledge": + handleKnowledgeCommand(s, i) + + case "status": + handleStatus(s, i) + + case "clear": + clearHistory(i.ChannelID) + reply := "🗑️ Gesprächsverlauf für diesen Channel gelöscht." + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Content: reply}, + }) } } @@ -318,6 +480,58 @@ func handleMemoryCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { handleAgentResponse(s, i, func() agents.Response { return memoryAgent.Handle(agents.Request{Action: agents.ActionIngest}) }) + case "url": + rawURL := sub.Options[0].StringValue() + handleAgentResponse(s, i, func() agents.Response { + n, err := brain.IngestURL(rawURL) + if err != nil { + return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim URL-Import: %v", err)} + } + return agents.Response{Text: fmt.Sprintf("✅ **%d Chunks** aus URL importiert:\n`%s`", n, rawURL)} + }) + case "profile": + text := sub.Options[0].StringValue() + handleAgentResponse(s, i, func() agents.Response { + if err := brain.AppendCoreMemory(text); err != nil { + return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)} + } + return agents.Response{Text: fmt.Sprintf("🧠 Kerngedächtnis aktualisiert: _%s_", text)} + }) + case "profile-show": + handleAgentResponse(s, i, func() agents.Response { + return agents.Response{Text: brain.ShowCoreMemory()} + }) + } +} + +func handleKnowledgeCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + sub := i.ApplicationCommandData().Options[0] + switch sub.Name { + case "list": + handleAgentResponse(s, i, func() agents.Response { + sources, err := brain.ListSources(0) + if err != nil { + return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler: %v", err)} + } + if len(sources) == 0 { + return agents.Response{Text: "📭 Keine Einträge in der Wissensdatenbank."} + } + var sb strings.Builder + fmt.Fprintf(&sb, "📚 **Quellen in der Wissensdatenbank** (%d):\n```\n", len(sources)) + for _, s := range sources { + fmt.Fprintf(&sb, "%s\n", s) + } + sb.WriteString("```") + return agents.Response{Text: sb.String()} + }) + case "delete": + source := sub.Options[0].StringValue() + handleAgentResponse(s, i, func() agents.Response { + if err := brain.DeleteBySource(source); err != nil { + return agents.Response{Error: err, Text: fmt.Sprintf("❌ Fehler beim Löschen: %v", err)} + } + return agents.Response{Text: fmt.Sprintf("🗑️ Quelle gelöscht: `%s`", source)} + }) } } @@ -348,11 +562,239 @@ func handleTaskCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleEmailCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { sub := i.ApplicationCommandData().Options[0] + + // Email-Move zeigt ein Select-Menü statt sofort alle zu verschieben + if sub.Name == agents.ActionEmailMove { + handleEmailMoveInit(s, i, sub) + return + } + + args := []string{sub.Name} + if sub.Name == agents.ActionEmailIngest && len(sub.Options) > 0 { + args = append(args, sub.Options[0].StringValue()) + } handleAgentResponse(s, i, func() agents.Response { - return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub.Name}}) + return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: args}) }) } +// handleEmailMoveInit zeigt ein Discord Select-Menü mit Emails zur Auswahl oder verschiebt direkt per Alter. +func handleEmailMoveInit(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) { + destName, alterDays := "", 0 + for _, opt := range sub.Options { + switch opt.Name { + case "ordner": + destName = opt.StringValue() + case "alter": + alterDays = int(opt.IntValue()) + } + } + + imapFolder, ok := tool.ResolveArchiveFolder(destName) + if !ok { + msg := fmt.Sprintf("❌ Unbekannter Ordner `%s`.", destName) + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Content: msg}, + }) + return + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + }) + + // Bulk-Verschieben aller Emails älter als N Tage — kein Select-Menü nötig + if alterDays > 0 { + n, err := email.MoveOldEmailsAllAccounts(imapFolder, alterDays) + var replyMsg string + if err != nil { + replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err) + } else if n == 0 { + replyMsg = fmt.Sprintf("📭 Keine Emails älter als %d Tage gefunden.", alterDays) + } else { + replyMsg = fmt.Sprintf("✅ %d Email(s) älter als %d Tage nach `%s` verschoben.", n, alterDays, imapFolder) + } + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &replyMsg}) + return + } + + allAccMsgs, err := email.FetchRecentForSelectAllAccounts(25) + if err != nil { + msg := fmt.Sprintf("❌ Emails konnten nicht abgerufen werden: %v", err) + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) + return + } + + totalEmails := 0 + for _, a := range allAccMsgs { + totalEmails += len(a.Messages) + } + if totalEmails == 0 { + msg := "📭 Keine Emails im Posteingang." + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) + return + } + + const maxOptions = 25 + var components []discordgo.MessageComponent + var headerParts []string + + for _, accData := range allAccMsgs { + if len(accData.Messages) == 0 { + continue + } + + msgs := accData.Messages + truncated := false + if len(msgs) > maxOptions { + msgs = msgs[:maxOptions] + truncated = true + } + + options := make([]discordgo.SelectMenuOption, 0, len(msgs)) + for _, m := range msgs { + label := m.Subject + if label == "" { + label = "(kein Betreff)" + } + if len([]rune(label)) > 97 { + label = string([]rune(label)[:97]) + "..." + } + desc := fmt.Sprintf("%s | %s", m.Date, m.From) + if len([]rune(desc)) > 97 { + desc = string([]rune(desc)[:97]) + "..." + } + options = append(options, discordgo.SelectMenuOption{ + Label: label, + Value: fmt.Sprintf("%d", m.SeqNum), + Description: desc, + }) + } + + customID := fmt.Sprintf("email_move:%s:%d", imapFolder, accData.AccIndex) + minVals := 1 + maxVals := len(options) + accLabel := accData.Account.Name + if accLabel == "" { + accLabel = accData.Account.User + } + placeholder := "Email(s) auswählen..." + if len(allAccMsgs) > 1 { + placeholder = fmt.Sprintf("Email(s) auswählen – %s...", accLabel) + } + + components = append(components, discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: customID, + Placeholder: placeholder, + MinValues: &minVals, + MaxValues: maxVals, + Options: options, + }, + }, + }) + + note := "" + if truncated { + note = fmt.Sprintf(" *(erste %d von %d)*", maxOptions, len(accData.Messages)) + } + if len(allAccMsgs) > 1 { + headerParts = append(headerParts, fmt.Sprintf("**%s**: %d Email(s)%s", accLabel, len(accData.Messages), note)) + } else { + headerParts = append(headerParts, fmt.Sprintf("%d Email(s)%s", len(accData.Messages), note)) + } + } + + msg := fmt.Sprintf("📧 **Emails nach `%s` verschieben**\n%s\nWähle aus welche Emails du verschieben möchtest:", imapFolder, strings.Join(headerParts, "\n")) + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &msg, + Components: &components, + }) +} + +// handleEmailMoveSelect verarbeitet die Discord Select-Menü Auswahl und verschiebt die gewählten Emails. +func handleEmailMoveSelect(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.MessageComponentData() + // CustomID-Format: email_move:: + parts := strings.SplitN(data.CustomID, ":", 3) + if len(parts) != 3 { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültige CustomID."}, + }) + return + } + imapFolder := parts[1] + accIndex, err := strconv.Atoi(parts[2]) + if err != nil { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Content: "❌ Interner Fehler: ungültiger Account-Index."}, + }) + return + } + + seqNums := make([]uint32, 0, len(data.Values)) + for _, v := range data.Values { + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + continue + } + seqNums = append(seqNums, uint32(n)) + } + if len(seqNums) == 0 { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Content: "❌ Keine gültigen Emails ausgewählt."}, + }) + return + } + + // DeferredMessageUpdate: zeigt Ladezustand, editiert dann die ursprüngliche Nachricht + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredMessageUpdate, + }) + + n, err := email.MoveSpecificUnread(accIndex, seqNums, imapFolder) + var replyMsg string + if err != nil { + replyMsg = fmt.Sprintf("❌ Fehler beim Verschieben: %v", err) + } else { + replyMsg = fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", n, imapFolder) + } + + // Menü entfernen nach Auswahl + emptyComponents := []discordgo.MessageComponent{} + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &replyMsg, + Components: &emptyComponents, + }) +} + +// handleStatus prüft alle externen Dienste und zeigt offene Task-Anzahl. +func handleStatus(s *discordgo.Session, i *discordgo.InteractionCreate) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + }) + + results, allOK := diag.RunAll() + + // Task-Zähler + store := task.NewStore() + open, err := store.OpenTasks() + taskInfo := "" + if err != nil { + taskInfo = "❌ Tasks: Fehler" + } else { + taskInfo = fmt.Sprintf("📋 Tasks: %d offen", len(open)) + } + + msg := strings.ReplaceAll(diag.Format(results, allOK), "Start-Diagnose", "Status") + "\n" + taskInfo + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg}) +} + func handleAskNoBrain(s *discordgo.Session, i *discordgo.InteractionCreate, question string) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, @@ -400,6 +842,23 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { return } + // Berechtigungsprüfung + if !isAllowed(m.Author.ID) { + s.ChannelMessageSendReply(m.ChannelID, "❌ Du bist nicht berechtigt, diesen Bot zu nutzen.", m.Reference()) + return + } + + // Datei-Anhänge prüfen (PDF) + for _, att := range m.Attachments { + ext := strings.ToLower(filepath.Ext(att.Filename)) + if ext == ".pdf" { + s.ChannelTyping(m.ChannelID) + reply := handlePDFAttachment(att) + s.ChannelMessageSendReply(m.ChannelID, reply, m.Reference()) + return + } + } + question := strings.TrimSpace( strings.ReplaceAll(m.Content, "<@"+botUser.ID+">", ""), ) @@ -417,6 +876,37 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { s.ChannelMessageSendReply(m.ChannelID, resp.Text, m.Reference()) } +// handlePDFAttachment lädt eine PDF-Datei herunter, importiert sie und gibt die Antwort zurück. +func handlePDFAttachment(att *discordgo.MessageAttachment) string { + slog.Info("PDF-Attachment erkannt", "datei", att.Filename, "url", att.URL) + + // PDF herunterladen + resp, err := http.Get(att.URL) //nolint:noctx + if err != nil { + return fmt.Sprintf("❌ PDF konnte nicht geladen werden: %v", err) + } + defer resp.Body.Close() + + // In temporäre Datei schreiben + tmpFile, err := os.CreateTemp("", "brain-pdf-*.pdf") + if err != nil { + return fmt.Sprintf("❌ Temporäre Datei konnte nicht erstellt werden: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + return fmt.Sprintf("❌ PDF konnte nicht gespeichert werden: %v", err) + } + tmpFile.Close() + + n, err := brain.IngestPDF(tmpFile.Name(), att.Filename) + if err != nil { + return fmt.Sprintf("❌ PDF-Import fehlgeschlagen: %v", err) + } + return fmt.Sprintf("✅ **%d Chunks** aus PDF importiert: `%s`", n, att.Filename) +} + // SendMessage schickt eine Nachricht proaktiv in einen Channel (für Daemon-Nutzung). func SendMessage(channelID, text string) error { if dg == nil { @@ -438,12 +928,18 @@ func routeMessage(text, author, channelID string) agents.Response { args := words[1:] switch cmd { + case "clear": + clearHistory(channelID) + return agents.Response{Text: "🗑️ Gesprächsverlauf gelöscht."} + case "email": sub := "summary" + emailArgs := []string{} if len(args) > 0 { sub = strings.ToLower(args[0]) + emailArgs = args[1:] // Restargumente (z.B. Ordnername für "move") } - return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: []string{sub}}) + return toolAgent.Handle(agents.Request{Action: agents.ActionEmail, Args: append([]string{sub}, emailArgs...)}) case "task": action := "list" @@ -507,28 +1003,24 @@ func sendWelcomeMessage() { **Slash-Commands:** ` + "```" + ` -/ask – Wissensdatenbank abfragen -/research – Alias für /ask -/asknobrain – Direkt ans LLM (kein RAG) -/memory store – Text in Wissensdatenbank speichern -/memory ingest – Markdown-Notizen neu einlesen +/ask – Wissensdatenbank abfragen +/research – Alias für /ask +/asknobrain – Direkt ans LLM (kein RAG) +/memory store – Text speichern +/memory ingest – Markdown-Notizen neu einlesen +/memory url – URL-Inhalt importieren +/memory profile – Fakt zum Kerngedächtnis hinzufügen +/memory profile-show – Kerngedächtnis anzeigen +/knowledge list – Gespeicherte Quellen auflisten +/knowledge delete – Quelle löschen /task add [--due YYYY-MM-DD] [--priority hoch|mittel|niedrig] -/task list – Alle Tasks anzeigen -/task done – Task erledigen -/task delete – Task löschen -/email summary – Letzte Emails zusammenfassen -/email unread – Ungelesene Emails zusammenfassen -/email remind – Termine aus Emails extrahieren +/task list / done / delete +/email summary / unread / remind / ingest / move / triage +/status – Bot-Status +/clear – Gesprächsverlauf zurücksetzen ` + "```" + ` -**@Mention:** -` + "```" + ` -@Brain – Wissensdatenbank (mit Chat-Gedächtnis) -@Brain task add [--due ...] [--priority ...] -@Brain task list / done / delete -@Brain email summary / unread / remind -@Brain remember -` + "```" + ` -⚙️ Daemon aktiv: Email-Check + tägliches Morgen-Briefing` +**@Mention:** PDF-Anhang schicken → automatisch importiert +⚙️ Daemon: IMAP IDLE + Morgen-Briefing + RSS-Feeds` if _, err := dg.ChannelMessageSend(channelID, msg); err != nil { log.Printf("⚠️ Willkommensnachricht konnte nicht gesendet werden: %v", err) @@ -545,7 +1037,73 @@ func getAuthor(i *discordgo.InteractionCreate) string { return "unknown" } +// patchEmailMoveChoices aktualisiert die /email move Choices in der commands-Liste nach dem Laden der Config. +// Wird in main() nach config.LoadConfig() aufgerufen. +func patchEmailMoveChoices() { + choices := buildMoveChoices() + for _, cmd := range commands { + if cmd.Name != "email" { + continue + } + for _, opt := range cmd.Options { + if opt.Name != "move" { + continue + } + for _, subOpt := range opt.Options { + if subOpt.Name == "ordner" { + subOpt.Choices = choices + return + } + } + } + } +} + +// buildMoveChoices erstellt Discord-Choices für /email move aus der konfigurierten archive_folders. +// Fallback: statische Liste (2Jahre/5Jahre/Archiv) wenn keine archive_folders konfiguriert. +func buildMoveChoices() []*discordgo.ApplicationCommandOptionChoice { + seen := map[string]bool{} + var choices []*discordgo.ApplicationCommandOptionChoice + + for _, acc := range config.AllEmailAccounts() { + for _, af := range acc.ArchiveFolders { + key := strings.ToLower(af.Name) + if seen[key] { + continue + } + seen[key] = true + label := af.Name + if af.RetentionDays > 0 { + label = fmt.Sprintf("%s (%d Tage)", af.Name, af.RetentionDays) + } + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: label, + Value: af.Name, + }) + if len(choices) == 25 { // Discord-Limit + slog.Warn("Mehr als 25 Archivordner konfiguriert, Liste wird gekürzt") + return choices + } + } + } + + if len(choices) == 0 { + // Legacy-Fallback + choices = []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Archiv (dauerhaft)", Value: "Archiv"}, + {Name: "5Jahre (~5 Jahre)", Value: "5Jahre"}, + {Name: "2Jahre (~2 Jahre)", Value: "2Jahre"}, + } + } + return choices +} + +// floatPtr gibt einen Pointer auf einen float64-Wert zurück (für MinValue in Discord-Options). +func floatPtr(v float64) *float64 { return &v } + // startDaemon läuft als Goroutine im Bot-Prozess und sendet proaktive Benachrichtigungen. +// Für Email-Benachrichtigungen wird IMAP IDLE genutzt (Echtzeit). +// Alternativ, wenn kein Email-Account konfiguriert ist, läuft nur der Morgen-Briefing-Timer. func startDaemon() { channelID := config.Cfg.Daemon.ChannelID if channelID == "" { @@ -553,53 +1111,121 @@ func startDaemon() { return } - emailInterval := time.Duration(config.Cfg.Daemon.EmailIntervalMin) * time.Minute - if emailInterval == 0 { - emailInterval = 30 * time.Minute - } reminderHour := config.Cfg.Daemon.TaskReminderHour if reminderHour == 0 { reminderHour = 8 } - log.Printf("⚙️ Daemon aktiv: Email-Check alle %v, Task-Reminder täglich um %02d:00", emailInterval, reminderHour) + // IMAP IDLE für jeden konfigurierten Account starten + ctx, cancel := context.WithCancel(context.Background()) + accounts := config.AllEmailAccounts() + if len(accounts) > 0 { + log.Printf("⚙️ Daemon aktiv: IMAP IDLE für %d Account(s), Task-Reminder täglich um %02d:00", len(accounts), reminderHour) + for _, acc := range accounts { + watcher := email.NewIdleWatcher(acc, func(accountName, summary string) { + slog.Info("IDLE: Neue Emails, sende Zusammenfassung", "account", accountName) + dg.ChannelMessageSend(channelID, fmt.Sprintf("📧 **Neue Emails (%s):**\n\n%s", accountName, summary)) + }) + go watcher.Run(ctx) + } + } else { + log.Printf("⚙️ Daemon aktiv (kein Email-Account): Task-Reminder täglich um %02d:00", reminderHour) + } - emailTicker := time.NewTicker(emailInterval) - defer emailTicker.Stop() + // RSS-Watcher starten (wenn Feeds konfiguriert) + if len(config.Cfg.RSSFeeds) > 0 { + log.Printf("⚙️ RSS-Watcher aktiv: %d Feed(s)", len(config.Cfg.RSSFeeds)) + rssWatcher := &rss.Watcher{ + OnResults: func(summary string) { + slog.Info("RSS: Feeds importiert") + dg.ChannelMessageSend(channelID, "🗞️ **RSS-Feeds importiert:**\n"+summary) + }, + } + go rssWatcher.Run(ctx) + } + + cleanupHour := config.Cfg.Daemon.CleanupHour + if cleanupHour == 0 { + cleanupHour = 2 + } + + ingestHour := config.Cfg.Daemon.IngestHour + if ingestHour == 0 { + ingestHour = 23 + } briefingTimer := scheduleDaily(reminderHour, 0) defer briefingTimer.Stop() + cleanupTimer := scheduleDaily(cleanupHour, 0) + defer cleanupTimer.Stop() + ingestTimer := scheduleDaily(ingestHour, 0) + defer ingestTimer.Stop() for { select { case <-daemonStop: slog.Info("Daemon gestoppt") + cancel() return - case <-emailTicker.C: - slog.Info("Daemon: Email-Check gestartet") - notify, err := email.SummarizeUnread() - if err != nil { - slog.Error("Daemon Email-Fehler", "fehler", err) - dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Email-Check fehlgeschlagen: %v", err)) - continue - } - if notify != "📭 Keine ungelesenen Emails." { - slog.Info("Daemon: Neue Emails gefunden, sende Zusammenfassung") - dg.ChannelMessageSend(channelID, "📧 **Neue Emails:**\n\n"+notify) - } else { - slog.Info("Daemon: Keine neuen Emails") - } - case <-briefingTimer.C: slog.Info("Daemon: Morgen-Briefing gestartet") dailyBriefing(channelID) briefingTimer.Stop() briefingTimer = scheduleDaily(reminderHour, 0) + + case <-cleanupTimer.C: + slog.Info("Daemon: Archiv-Aufräumen gestartet") + go func() { + summary, err := email.CleanupArchiveFolders() + if err != nil { + slog.Error("Daemon: Archiv-Aufräumen Fehler", "fehler", err) + } else { + slog.Info("Daemon: Archiv-Aufräumen abgeschlossen", "ergebnis", summary) + } + }() + cleanupTimer.Stop() + cleanupTimer = scheduleDaily(cleanupHour, 0) + + case <-ingestTimer.C: + slog.Info("Daemon: Nächtlicher Email-Ingest gestartet") + go nightlyIngest(channelID) + ingestTimer.Stop() + ingestTimer = scheduleDaily(ingestHour, 0) } } } +// nightlyIngest importiert Emails aus allen Archiv-Ordnern in die Wissensdatenbank. +func nightlyIngest(channelID string) { + accounts := config.AllEmailAccounts() + total := 0 + var errs []string + + for _, acc := range accounts { + for _, af := range acc.ArchiveFolders { + n, err := brain.IngestEmailFolder(acc, af.IMAPFolder, 0) + if err != nil { + slog.Error("Nacht-Ingest Fehler", "account", acc.Name, "folder", af.IMAPFolder, "fehler", err) + errs = append(errs, fmt.Sprintf("%s/%s: %v", acc.Name, af.IMAPFolder, err)) + continue + } + slog.Info("Nacht-Ingest abgeschlossen", "account", acc.Name, "folder", af.IMAPFolder, "ingested", n) + total += n + } + } + + if channelID == "" { + return + } + if len(errs) > 0 { + dg.ChannelMessageSend(channelID, fmt.Sprintf("⚠️ Nacht-Ingest: %d Emails importiert, %d Fehler:\n%s", + total, len(errs), strings.Join(errs, "\n"))) + } else if total > 0 { + dg.ChannelMessageSend(channelID, fmt.Sprintf("🗄️ Nacht-Ingest: %d Emails aus Archiv-Ordnern importiert.", total)) + } +} + // scheduleDaily gibt einen Timer zurück, der zum nächsten Auftreten von hour:minute feuert. func scheduleDaily(hour, minute int) *time.Timer { now := time.Now() @@ -623,6 +1249,7 @@ func dailyBriefing(channelID string) { open, err := store.OpenTasks() if err != nil { slog.Error("Daemon Briefing Task-Fehler", "fehler", err) + open = nil // explizit nil, len(open)==0 → kein Task-Abschnitt } else if len(open) > 0 { fmt.Fprintf(&taskSection, "📋 **Offene Tasks (%d):**\n", len(open)) for _, t := range open { diff --git a/doc/architecture.md b/doc/architecture.md index e6298ec..b5b32b7 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -1,60 +1,234 @@ -# Architektur - -``` -AI_Brain/ - *.md Dateien - │ - ▼ - bin/ingest Embeddings via LocalAI - │ - ▼ - Qdrant (NAS) ◄──── bin/ask ──► LM Studio (Chat) -``` - -- **Embeddings**: LocalAI unter `embedding.url` (Modell konfigurierbar) -- **Vektordatenbank**: Qdrant auf dem NAS -- **Chat-Completion**: LocalAI unter `chat.url` (Modell konfigurierbar) - -## Projektstruktur - -``` -AI-Agent/ - cmd/ - ingest/main.go Entry Point für ingest-Binary - ask/main.go Entry Point für ask-Binary - internal/ - config/config.go Config-Struct, Clients, Verbindungen - brain/ - ingest.go Markdown-Import, Chunking - ingest_json.go JSON-Import (Bildbeschreibungen) - ask.go Suche + LLM-Antwort - bin/ Kompilierte Binaries (von build.sh erzeugt) - config.yml Alle Einstellungen - build.sh Baut beide Binaries -``` - -## Konfiguration - -Alle Einstellungen in `config.yml` (muss im Arbeitsverzeichnis liegen): - -```yaml -qdrant: - host: "192.168.1.4" - port: "6334" - api_key: "..." - collection: "jacek-brain" - -embedding: - url: "http://192.168.1.118:8080/v1" - model: "qwen3-embedding-4b" - dimensions: 2560 # muss zum Modell passen - -chat: - url: "http://192.168.1.118:8080/v1" - model: "qwen3.5-4b-claude-4.6-opus-reasoning-distilled" - -brain_root: "/mnt/c/Users/jacek/AI_Brain" -top_k: 3 -``` - -> **Wichtig:** Wenn du `embedding.model` oder `dimensions` änderst, muss die Qdrant-Collection neu erstellt werden (im Dashboard löschen, dann `ingest` erneut ausführen). +# Architektur + +## Übersicht + +``` +Discord (primäres Interface) + ↓ Slash-Commands + @Mention + PDF-Anhänge +cmd/discord/main.go + ├── Research-Agent → brain.AskQuery() + Konversationsverlauf pro Channel + ├── Memory-Agent → brain.RunIngest(), IngestChatMessage(), IngestURL(), CoreMemory + ├── Task-Agent → tasks.json (atomares JSON, DueDate + Priority) + ├── Tool-Agent → Dispatcher für Email-Aktionen + └── Daemon-Goroutinen: + ├── IMAP IDLE (pro Account) → Echtzeit-Triage + Discord-Benachrichtigung + ├── RSS-Watcher → Artikel-Import in Qdrant + ├── Morgen-Briefing (08:00) → Tasks + Emails kombiniert + ├── Archiv-Cleanup (02:00) → CleanupArchiveFolders() nach retention_days + └── Nacht-Ingest (23:00) → brain.IngestEmailFolder() für alle Archive + ↓ +Qdrant (gRPC, 192.168.1.4:6334) LocalAI (HTTP, 192.168.1.118:8080) + Vektordatenbank Embedding-Modell + Chat-Modell +``` + +--- + +## Packages + +### `cmd/discord/` +Primärer Einstiegspunkt. Registriert Discord Slash-Commands, verarbeitet Interaktionen und @Mentions, startet den Daemon. + +**Wichtige Funktionen:** +- `main()` — Config laden, Discord verbinden, Commands registrieren, Daemon starten +- `onInteraction()` — Slash-Command-Handler mit Berechtigungsprüfung +- `onMessage()` — @Mention-Handler inkl. PDF-Anhang-Erkennung +- `routeMessage()` — Leitet @Mention-Text an passenden Agenten weiter +- `startDaemon()` — Startet IMAP IDLE, RSS-Watcher, tägliche Timer +- `dailyBriefing()` — Morgen-Briefing (Tasks + Emails) +- `nightlyIngest()` — Archiv-Ordner in Qdrant importieren +- `patchEmailMoveChoices()` — Discord-Choices dynamisch aus Config befüllen +- `isAllowed(userID)` — User-Berechtigungsprüfung + +### `internal/config/` +Konfigurationsstruktur (`Config`), Client-Factories und `AllEmailAccounts()`. + +```go +type Config struct { + Qdrant, Embedding, Chat // Externe Dienste + Discord // Token, GuildID, AllowedUsers + Email / EmailAccounts // IMAP (Legacy/Multi-Account) + Tasks // JSON-Pfad + Daemon // Timer-Uhrzeiten, Channel-ID + BrainRoot, TopK, ScoreThreshold + RSSFeeds // RSS-Feed-URLs + Intervalle +} +``` + +- `LoadConfig()` — liest `config.yml`, validiert Pflichtfelder +- `AllEmailAccounts()` — gibt alle Accounts zurück (Multi-Account-Vorrang über Legacy) +- `NewQdrantConn()`, `NewEmbeddingClient()`, `NewChatClient()` — Client-Factories + +### `internal/brain/` +Core RAG-Logik. Alle Funktionen sind zustandslos (keine globalen Verbindungen). + +| Datei | Inhalt | +|-------|--------| +| `ask.go` | `AskQuery()` (Suche + LLM), `ChatDirect()`, `searchKnowledge()` | +| `ingest.go` | `RunIngest()` (Markdown), `IngestChatMessage()`, `IngestText()`, Chunking | +| `ingest_json.go` | JSON-Import (Bildbeschreibungen) | +| `ingest_email.go` | `IngestEmailFolder()` — IMAP-Ordner → Qdrant | +| `ingest_url.go` | `IngestURL()` — HTTP-Fetch + HTML-Text-Extraktion → Qdrant | +| `ingest_pdf.go` | `IngestPDF()` — PDF-Text-Extraktion → Qdrant | +| `knowledge.go` | `ListSources()` (Scroll), `DeleteBySource()` (Filter-Delete) | +| `core_memory.go` | `LoadCoreMemory()`, `AppendCoreMemory()` — `brain_root/core_memory.md` | + +**ID-Schema:** `SHA256(source + ":" + text)[:16]` als Hex — deterministische Upserts, keine Duplikate. + +**Chunking-Strategie:** +1. Text nach Markdown-Überschriften (`#`, `##`, `###`) aufteilen +2. Abschnitte > 800 Zeichen nach Paragraphen (`\n\n`) aufteilen +3. Minimum-Länge 20 Zeichen + +**Core Memory in AskQuery:** +``` +System-Prompt = Basis-Prompt + + "\n\n## Fakten über den Nutzer:\n" + core_memory.md (wenn nicht leer) +``` + +### `internal/agents/` + +**`agent.go`** — Gemeinsame Interfaces: +```go +type Agent interface { Handle(Request) Response } +type Request struct { Action, Args, Author, Source, History } +type Response struct { Text, RawAnswer string; Error error } +type HistoryMessage struct { Role, Content string } +``` + +**`actions.go`** — Alle Action-Konstanten (typsicher, keine Magic Strings). + +**`memory/agent.go`** — Delegiert an `brain.*`: +- `store` → `brain.IngestChatMessage()` +- `ingest` → `brain.RunIngest()` + +**`research/agent.go`** — Ruft `brain.AskQuery()` auf, formatiert Antwort mit Quellenangaben. + +**`task/agent.go`** + **`task/store.go`** — Task-CRUD über atomares `tasks.json`: +- Felder: `ID` (UUID), `Text`, `Done bool`, `DueDate *time.Time`, `Priority string` +- Atomares Schreiben: temp-Datei → rename (kein Datenverlust bei Absturz) + +### `internal/agents/tool/` + +**`agent.go`** — Dispatcht Email-Actions an `email`-Package-Funktionen. `ResolveArchiveFolder()` für case-insensitive Ordnersuche. + +**`email/client.go`** — IMAP-Client-Wrapper: +- `ConnectAccount()` — Verbindung (STARTTLS oder implizites TLS) +- `FetchUnread()`, `FetchWithBody()`, `FetchRecentForSelect()` +- `MoveMessages()`, `MoveOldEmails()`, `DeleteByAge()` + +**`email/summary.go`** — Email-Zusammenfassung + Triage: +- `SummarizeUnread()` — Alle Accounts, LLM-Zusammenfassung +- `triageUnread()` — LLM-Klassifikation + Qdrant-Lernen via `triage` Package +- `CleanupArchiveFolders()` — Alte Emails löschen nach `retention_days` + +**`email/idle.go`** — IMAP IDLE-Watcher: +- `IdleWatcher` — pro Account, race-sicher mit `atomic.Uint32` +- Automatischer Reconnect nach 60s bei Fehler +- Callback bei neuen Emails → `triageUnread()` + Discord-Nachricht + +**`rss/watcher.go`** — RSS-Feed-Watcher: +- `IngestFeed(url)` — gofeed Parser → Artikel als Text → `brain.IngestText()` +- `Watcher.Run(ctx)` — Goroutine, pollt alle Feeds im minimalen Intervall +- `IngestAllFeeds()` / `FormatResults()` — Batch-Import + Discord-Formatting + +### `internal/triage/` +Eigenes Package um Import-Zyklen zu vermeiden (`brain` ↔ `email` ↔ `triage`). +- `StoreTriage()` — Triage-Entscheidung in Qdrant speichern (Typ `email_triage`) +- `SearchSimilar()` — Ähnliche frühere Entscheidungen finden (Score ≥ 0.7) als Few-Shot-Kontext + +### `internal/diag/` +- `RunAll()` — Prüft Qdrant, LocalAI (Embedding + Chat), IMAP-Verbindungen +- `Format()` / `Log()` — Ausgabe für Discord und Konsole + +--- + +## Datenflüsse + +### Slash-Command: `/ask ` +``` +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 | diff --git a/go.mod b/go.mod index 2ce9b3e..d09cce1 100755 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module my-brain-importer -go 1.22.2 +go 1.24.1 require ( github.com/bwmarrin/discordgo v0.29.0 @@ -12,9 +12,17 @@ require ( ) require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gorilla/websocket v1.4.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // indirect + github.com/mmcdole/gofeed v1.3.0 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect diff --git a/go.sum b/go.sum index af3c555..becfabc 100755 --- a/go.sum +++ b/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/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= @@ -14,14 +20,31 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qdrant/go-client v1.12.0 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T4M= github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw= github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY= github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= @@ -44,6 +67,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= @@ -53,6 +77,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -64,6 +89,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/internal/agents/actions.go b/internal/agents/actions.go index 3d4ba1a..91fa9bd 100644 --- a/internal/agents/actions.go +++ b/internal/agents/actions.go @@ -15,9 +15,22 @@ const ( ActionDone = "done" ActionDelete = "delete" + // Memory + ActionIngestURL = "url" + ActionIngestPDF = "pdf" + ActionProfile = "profile" + ActionProfileShow = "profile-show" + + // Knowledge + ActionKnowledgeList = "list" + ActionKnowledgeDelete = "delete" + // Tool/Email - ActionEmail = "email" + ActionEmail = "email" ActionEmailSummary = "summary" ActionEmailUnread = "unread" ActionEmailRemind = "remind" + ActionEmailIngest = "ingest" + ActionEmailMove = "move" + ActionEmailTriage = "triage" ) diff --git a/internal/agents/tool/agent.go b/internal/agents/tool/agent.go index 4465ce4..5780525 100644 --- a/internal/agents/tool/agent.go +++ b/internal/agents/tool/agent.go @@ -3,9 +3,12 @@ package tool import ( "fmt" + "strings" "my-brain-importer/internal/agents" "my-brain-importer/internal/agents/tool/email" + "my-brain-importer/internal/brain" + "my-brain-importer/internal/config" ) // Agent verteilt Tool-Anfragen an spezialisierte Sub-Agenten. @@ -41,8 +44,14 @@ func (a *Agent) handleEmail(req agents.Request) agents.Response { result, err = email.SummarizeUnread() case agents.ActionEmailRemind: result, err = email.ExtractReminders() + case agents.ActionEmailIngest: + return a.handleEmailIngest(req) + case agents.ActionEmailMove: + return a.handleEmailMove(req) + case agents.ActionEmailTriage: + return a.handleEmailTriage() default: - return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind", subAction)} + return agents.Response{Text: fmt.Sprintf("❌ Unbekannte Email-Aktion `%s`. Verfügbar: summary, unread, remind, ingest, move, triage", subAction)} } if err != nil { @@ -50,3 +59,144 @@ func (a *Agent) handleEmail(req agents.Request) agents.Response { } return agents.Response{Text: "📧 **Email-Analyse:**\n\n" + result} } + +// handleEmailIngest importiert Emails aus einem IMAP-Ordner in Qdrant. +// Args[1] = Ordnername (Standard: "Archiv") +func (a *Agent) handleEmailIngest(req agents.Request) agents.Response { + folder := "Archiv" + if len(req.Args) > 1 && req.Args[1] != "" { + folder = req.Args[1] + } + + accounts := config.AllEmailAccounts() + if len(accounts) == 0 { + return agents.Response{Text: "❌ Kein Email-Account konfiguriert."} + } + + total := 0 + var errs []string + for _, acc := range accounts { + n, err := brain.IngestEmailFolder(acc, folder, 500) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", acc.Name, err)) + continue + } + total += n + } + + if len(errs) > 0 && total == 0 { + return agents.Response{Text: fmt.Sprintf("❌ Email-Ingest fehlgeschlagen:\n%s", joinLines(errs))} + } + + msg := fmt.Sprintf("✅ **Email-Ingest abgeschlossen:** %d Emails aus `%s` in die Wissensdatenbank importiert.", total, folder) + if len(errs) > 0 { + msg += "\n⚠️ Fehler bei einigen Accounts:\n" + joinLines(errs) + } + return agents.Response{Text: msg} +} + +// handleEmailMove verschiebt alle ungelesenen Emails in einen konfigurierten Archivordner. +// Args[1] = Zielordner-Name (aus archive_folders in config oder Legacy: 2Jahre/5Jahre/Archiv) +func (a *Agent) handleEmailMove(req agents.Request) agents.Response { + if len(req.Args) < 2 || req.Args[1] == "" { + return agents.Response{Text: "❌ Zielordner fehlt. " + buildMoveFoldersHint()} + } + dest := req.Args[1] + + imapFolder, ok := resolveArchiveFolder(dest) + if !ok { + return agents.Response{Text: fmt.Sprintf("❌ Unbekannter Ordner `%s`. %s", dest, buildMoveFoldersHint())} + } + + accounts := config.AllEmailAccounts() + if len(accounts) == 0 { + return agents.Response{Text: "❌ Kein Email-Account konfiguriert."} + } + + total := 0 + var errs []string + for _, acc := range accounts { + n, err := email.MoveUnread(acc, imapFolder) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", acc.Name, err)) + continue + } + total += n + } + + if len(errs) > 0 && total == 0 { + return agents.Response{Text: fmt.Sprintf("❌ Verschieben fehlgeschlagen:\n%s", joinLines(errs))} + } + if total == 0 { + return agents.Response{Text: fmt.Sprintf("📭 Keine ungelesenen Emails zum Verschieben nach `%s`.", imapFolder)} + } + + msg := fmt.Sprintf("✅ **%d Email(s)** nach `%s` verschoben.", total, imapFolder) + if len(errs) > 0 { + msg += "\n⚠️ Fehler:\n" + joinLines(errs) + } + return agents.Response{Text: msg} +} + +// handleEmailTriage klassifiziert die letzten 10 Emails aller Accounts und verschiebt sie. +func (a *Agent) handleEmailTriage() agents.Response { + result, err := email.TriageRecentAllAccounts(10) + if err != nil { + return agents.Response{Error: err, Text: fmt.Sprintf("❌ Triage fehlgeschlagen: %v", err)} + } + return agents.Response{Text: "🗂️ **Email-Triage (letzte 10 Emails):**\n\n" + result} +} + +// ResolveArchiveFolder ist die exportierte Version von resolveArchiveFolder für den Discord-Layer. +func ResolveArchiveFolder(name string) (imapFolder string, ok bool) { + return resolveArchiveFolder(name) +} + +// resolveArchiveFolder sucht den IMAP-Ordnernamen für einen Anzeigenamen aus der Config. +// Fallback: Legacy-Hardcoding für 2Jahre/5Jahre/Archiv wenn keine archive_folders konfiguriert. +func resolveArchiveFolder(name string) (imapFolder string, ok bool) { + for _, acc := range config.AllEmailAccounts() { + for _, af := range acc.ArchiveFolders { + if strings.EqualFold(af.Name, name) || strings.EqualFold(af.IMAPFolder, name) { + return af.IMAPFolder, true + } + } + } + // Legacy-Fallback für Konfigurationen ohne archive_folders + legacy := map[string]string{ + "2jahre": "2Jahre", + "5jahre": "5Jahre", + "archiv": "Archiv", + } + if canonical, found := legacy[strings.ToLower(name)]; found { + return canonical, true + } + return "", false +} + +// buildMoveFoldersHint gibt eine Hinweis-Nachricht mit verfügbaren Archivordnern zurück. +func buildMoveFoldersHint() string { + seen := map[string]bool{} + var names []string + for _, acc := range config.AllEmailAccounts() { + for _, af := range acc.ArchiveFolders { + key := strings.ToLower(af.Name) + if !seen[key] { + seen[key] = true + names = append(names, fmt.Sprintf("`%s`", af.Name)) + } + } + } + if len(names) == 0 { + return "Verfügbar: `2Jahre`, `5Jahre`, `Archiv`" + } + return fmt.Sprintf("Verfügbar: %s", strings.Join(names, ", ")) +} + +func joinLines(lines []string) string { + result := "" + for _, l := range lines { + result += "• " + l + "\n" + } + return result +} diff --git a/internal/agents/tool/email/client.go b/internal/agents/tool/email/client.go index 5ed152e..82d514a 100644 --- a/internal/agents/tool/email/client.go +++ b/internal/agents/tool/email/client.go @@ -3,7 +3,12 @@ package email import ( "crypto/tls" + "encoding/base64" "fmt" + "log/slog" + "mime/quotedprintable" + "strings" + "time" imap "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" @@ -18,15 +23,43 @@ type Message struct { Date string } -// Client wraps die IMAP-Verbindung. -type Client struct { - c *imapclient.Client +// SelectMessage koppelt eine Message mit ihrer IMAP-Sequenznummer für UI-Zwecke. +type SelectMessage struct { + Message + SeqNum uint32 + Unread bool // true = \Seen flag nicht gesetzt } -// Connect öffnet eine IMAP-Verbindung. +// MessageWithBody repräsentiert eine Email mit Text-Inhalt (für Datenbankimport). +type MessageWithBody struct { + Message + Body string +} + +// Client wraps die IMAP-Verbindung. +type Client struct { + c *imapclient.Client + folder string // INBOX-Ordner (leer = "INBOX") +} + +// Connect öffnet eine IMAP-Verbindung mit dem Legacy-Email-Block aus der Config. func Connect() (*Client, error) { cfg := config.Cfg.Email - addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + acc := config.EmailAccount{ + Host: cfg.Host, + Port: cfg.Port, + User: cfg.User, + Password: cfg.Password, + TLS: cfg.TLS, + StartTLS: cfg.StartTLS, + Folder: cfg.Folder, + } + return ConnectAccount(acc) +} + +// ConnectAccount öffnet eine IMAP-Verbindung für einen bestimmten EmailAccount. +func ConnectAccount(acc config.EmailAccount) (*Client, error) { + addr := fmt.Sprintf("%s:%d", acc.Host, acc.Port) var ( c *imapclient.Client @@ -34,11 +67,11 @@ func Connect() (*Client, error) { ) switch { - case cfg.TLS: - tlsCfg := &tls.Config{ServerName: cfg.Host} + case acc.TLS: + tlsCfg := &tls.Config{ServerName: acc.Host} c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg}) - case cfg.StartTLS: - tlsCfg := &tls.Config{ServerName: cfg.Host} + case acc.StartTLS: + tlsCfg := &tls.Config{ServerName: acc.Host} c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg}) default: c, err = imapclient.DialInsecure(addr, nil) @@ -47,12 +80,12 @@ func Connect() (*Client, error) { return nil, fmt.Errorf("IMAP verbinden: %w", err) } - if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil { + if err := c.Login(acc.User, acc.Password).Wait(); err != nil { c.Close() return nil, fmt.Errorf("IMAP login: %w", err) } - return &Client{c: c}, nil + return &Client{c: c, folder: acc.Folder}, nil } // Close schließt die Verbindung. @@ -61,9 +94,28 @@ func (cl *Client) Close() { cl.c.Close() } +// EnsureFolder legt einen IMAP-Ordner an falls er nicht existiert. +// Strato-kompatibel: ignoriert alle "already exists"-Varianten. +func (cl *Client) EnsureFolder(folder string) error { + err := cl.c.Create(folder, nil).Wait() + if err == nil { + slog.Info("IMAP: Ordner angelegt", "ordner", folder) + return nil + } + errLower := strings.ToLower(err.Error()) + if strings.Contains(errLower, "already exists") || + strings.Contains(errLower, "alreadyexists") || + strings.Contains(errLower, "mailbox exists") || + strings.Contains(errLower, "exists") { + return nil // Ordner existiert bereits — kein Fehler + } + slog.Error("IMAP: Ordner anlegen fehlgeschlagen", "ordner", folder, "fehler", err) + return fmt.Errorf("IMAP create folder %s: %w", folder, err) +} + // FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body). func (cl *Client) FetchRecent(n uint32) ([]Message, error) { - folder := config.Cfg.Email.Folder + folder := cl.folder if folder == "" { folder = "INBOX" } @@ -94,7 +146,7 @@ func (cl *Client) FetchRecent(n uint32) ([]Message, error) { // FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body). func (cl *Client) FetchUnread() ([]Message, error) { - folder := config.Cfg.Email.Folder + folder := cl.folder if folder == "" { folder = "INBOX" } @@ -129,7 +181,7 @@ func (cl *Client) FetchUnread() ([]Message, error) { // FetchUnreadSeqNums holt ungelesene Emails und gibt zusätzlich die Sequenznummern zurück. // Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben). func (cl *Client) FetchUnreadSeqNums() ([]Message, []uint32, error) { - folder := config.Cfg.Email.Folder + folder := cl.folder if folder == "" { folder = "INBOX" } @@ -172,6 +224,328 @@ func (cl *Client) MoveMessages(seqNums []uint32, destFolder string) error { return nil } +// FetchUnreadForSelect gibt ungelesene Emails mit ihren Sequenznummern zurück. +// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben). +func (cl *Client) FetchUnreadForSelect() ([]SelectMessage, error) { + folder := cl.folder + if folder == "" { + folder = "INBOX" + } + + if _, err := cl.c.Select(folder, nil).Wait(); err != nil { + return nil, fmt.Errorf("IMAP select: %w", err) + } + + searchData, err := cl.c.Search(&imap.SearchCriteria{ + NotFlag: []imap.Flag{imap.FlagSeen}, + }, nil).Wait() + if err != nil { + return nil, fmt.Errorf("IMAP search: %w", err) + } + + seqNums := searchData.AllSeqNums() + if len(seqNums) == 0 { + return nil, nil + } + + var seqSet imap.SeqSet + seqSet.AddNum(seqNums...) + + rawMsgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect() + if err != nil { + return nil, fmt.Errorf("IMAP fetch: %w", err) + } + + seqToMsg := make(map[uint32]*imapclient.FetchMessageBuffer, len(rawMsgs)) + for _, m := range rawMsgs { + if m.Envelope != nil { + seqToMsg[m.SeqNum] = m + } + } + + result := make([]SelectMessage, 0, len(seqNums)) + for _, sn := range seqNums { + m, ok := seqToMsg[sn] + if !ok { + continue + } + result = append(result, SelectMessage{ + Message: parseMessage(m), + SeqNum: sn, + Unread: true, + }) + } + return result, nil +} + +// FetchRecentForSelect gibt die letzten n Emails mit Sequenznummern und Unread-Status zurück. +// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben). +func (cl *Client) FetchRecentForSelect(n uint32) ([]SelectMessage, error) { + folder := cl.folder + if folder == "" { + folder = "INBOX" + } + + selectData, err := cl.c.Select(folder, nil).Wait() + if err != nil { + return nil, fmt.Errorf("IMAP select: %w", err) + } + if selectData.NumMessages == 0 { + return nil, nil + } + + start := uint32(1) + if selectData.NumMessages > n { + start = selectData.NumMessages - n + 1 + } + + var seqSet imap.SeqSet + seqSet.AddRange(start, selectData.NumMessages) + + rawMsgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true, Flags: true}).Collect() + if err != nil { + return nil, fmt.Errorf("IMAP fetch: %w", err) + } + + result := make([]SelectMessage, 0, len(rawMsgs)) + for _, m := range rawMsgs { + if m.Envelope == nil { + continue + } + unread := true + for _, f := range m.Flags { + if f == imap.FlagSeen { + unread = false + break + } + } + result = append(result, SelectMessage{ + Message: parseMessage(m), + SeqNum: m.SeqNum, + Unread: unread, + }) + } + return result, nil +} + +// MoveOldMessages verschiebt alle Emails im Ordner, die älter als olderThanDays Tage sind, nach destFolder. +// Gibt die Anzahl verschobener Nachrichten zurück. olderThanDays <= 0 ist ein No-op. +func (cl *Client) MoveOldMessages(folder, destFolder string, olderThanDays int) (int, error) { + if olderThanDays <= 0 { + return 0, nil + } + if folder == "" { + folder = "INBOX" + } + + cutoff := time.Now().AddDate(0, 0, -olderThanDays).Truncate(24 * time.Hour) + + if _, err := cl.c.Select(folder, nil).Wait(); err != nil { + return 0, fmt.Errorf("IMAP select %s: %w", folder, err) + } + + searchData, err := cl.c.Search(&imap.SearchCriteria{Before: cutoff}, nil).Wait() + if err != nil { + return 0, fmt.Errorf("IMAP search: %w", err) + } + + seqNums := searchData.AllSeqNums() + if len(seqNums) == 0 { + return 0, nil + } + + var seqSet imap.SeqSet + seqSet.AddNum(seqNums...) + + if _, err := cl.c.Move(seqSet, destFolder).Wait(); err != nil { + return 0, fmt.Errorf("IMAP move: %w", err) + } + + return len(seqNums), nil +} + +// MoveSpecificMessages selektiert den Inbox-Ordner und verschiebt die angegebenen Sequenznummern. +func (cl *Client) MoveSpecificMessages(seqNums []uint32, destFolder string) error { + folder := cl.folder + if folder == "" { + folder = "INBOX" + } + if _, err := cl.c.Select(folder, nil).Wait(); err != nil { + return fmt.Errorf("IMAP select: %w", err) + } + return cl.MoveMessages(seqNums, destFolder) +} + +// CleanupOldEmails löscht Emails im Ordner, die älter als retentionDays sind. +// Gibt die Anzahl gelöschter Nachrichten zurück. retentionDays <= 0 ist ein No-op. +func (cl *Client) CleanupOldEmails(folder string, retentionDays int) (int, error) { + if retentionDays <= 0 { + return 0, nil + } + if folder == "" { + folder = "INBOX" + } + + cutoff := time.Now().AddDate(0, 0, -retentionDays).Truncate(24 * time.Hour) + + if _, err := cl.c.Select(folder, nil).Wait(); err != nil { + return 0, fmt.Errorf("IMAP select %s: %w", folder, err) + } + + searchData, err := cl.c.Search(&imap.SearchCriteria{Before: cutoff}, nil).Wait() + if err != nil { + return 0, fmt.Errorf("IMAP search: %w", err) + } + + seqNums := searchData.AllSeqNums() + if len(seqNums) == 0 { + return 0, nil + } + + var seqSet imap.SeqSet + seqSet.AddNum(seqNums...) + + storeFlags := &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagDeleted}, + } + if _, err := cl.c.Store(seqSet, storeFlags, nil).Collect(); err != nil { + return 0, fmt.Errorf("IMAP store flags: %w", err) + } + + if _, err := cl.c.Expunge().Collect(); err != nil { + return 0, fmt.Errorf("IMAP expunge: %w", err) + } + + return len(seqNums), nil +} + +// FetchWithBody holt bis zu n Emails aus dem angegebenen Ordner mit Text-Body. +// Emails werden in Batches von 50 gefetcht um den IMAP-Server nicht zu überlasten. +func (cl *Client) FetchWithBody(folder string, n uint32) ([]MessageWithBody, error) { + if folder == "" { + folder = "INBOX" + } + + selectData, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait() + if err != nil { + return nil, fmt.Errorf("IMAP select %s: %w", folder, err) + } + if selectData.NumMessages == 0 { + return nil, nil + } + + // Letzte n Nachrichten + total := selectData.NumMessages + start := uint32(1) + if total > n { + start = total - n + 1 + } + + bodySec := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText} + hdrSec := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader} + + var result []MessageWithBody + batchSize := uint32(50) + + for i := start; i <= total; i += batchSize { + end := i + batchSize - 1 + if end > total { + end = total + } + var seqSet imap.SeqSet + seqSet.AddRange(i, end) + + msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{ + Envelope: true, + BodySection: []*imap.FetchItemBodySection{bodySec, hdrSec}, + }).Collect() + if err != nil { + return nil, fmt.Errorf("IMAP fetch batch %d-%d: %w", i, end, err) + } + + for _, msg := range msgs { + if msg.Envelope == nil { + continue + } + m := MessageWithBody{Message: parseMessage(msg)} + + // Content-Transfer-Encoding aus Header lesen + enc := "" + if hdr := msg.FindBodySection(hdrSec); hdr != nil { + for _, line := range strings.Split(string(hdr), "\n") { + if strings.HasPrefix(strings.ToLower(line), "content-transfer-encoding:") { + enc = strings.TrimSpace(strings.ToLower(strings.SplitN(line, ":", 2)[1])) + } + } + } + + if body := msg.FindBodySection(bodySec); body != nil { + m.Body = decodeBody(body, enc) + } + result = append(result, m) + } + } + return result, nil +} + +// decodeBody dekodiert einen Email-Body je nach Content-Transfer-Encoding. +func decodeBody(raw []byte, enc string) string { + var text string + switch enc { + case "base64": + cleaned := strings.ReplaceAll(strings.TrimSpace(string(raw)), "\r\n", "") + if decoded, err := base64.StdEncoding.DecodeString(cleaned); err == nil { + text = string(decoded) + } else if decoded, err := base64.RawStdEncoding.DecodeString(cleaned); err == nil { + text = string(decoded) + } else { + text = string(raw) // Fallback: roh + } + case "quoted-printable": + r := quotedprintable.NewReader(strings.NewReader(string(raw))) + if buf := new(strings.Builder); true { + buf.Grow(len(raw)) + tmp := make([]byte, 4096) + for { + n, err := r.Read(tmp) + buf.Write(tmp[:n]) + if err != nil { + break + } + } + text = buf.String() + } + default: + text = string(raw) + } + + // Kürzen auf max 2000 Zeichen + text = strings.TrimSpace(text) + if len(text) > 2000 { + text = text[:2000] + } + return text +} + +// parseMessage extrahiert eine Message aus einem FetchMessageBuffer. +func parseMessage(msg *imapclient.FetchMessageBuffer) Message { + m := Message{ + Subject: msg.Envelope.Subject, + Date: msg.Envelope.Date.Format("2006-01-02 15:04"), + } + if len(msg.Envelope.From) > 0 { + addr := msg.Envelope.From[0] + if addr.Name != "" { + m.From = fmt.Sprintf("%s <%s@%s>", addr.Name, addr.Mailbox, addr.Host) + } else { + m.From = fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host) + } + } + return m +} + func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message { result := make([]Message, 0, len(msgs)) for _, msg := range msgs { diff --git a/internal/agents/tool/email/idle.go b/internal/agents/tool/email/idle.go new file mode 100644 index 0000000..d1a7756 --- /dev/null +++ b/internal/agents/tool/email/idle.go @@ -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) +} diff --git a/internal/agents/tool/email/summary.go b/internal/agents/tool/email/summary.go index 33bf4fd..e7f6ca3 100644 --- a/internal/agents/tool/email/summary.go +++ b/internal/agents/tool/email/summary.go @@ -11,60 +11,510 @@ import ( openai "github.com/sashabaranov/go-openai" "my-brain-importer/internal/config" + "my-brain-importer/internal/triage" ) // Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen. func Summarize() (string, error) { - return fetchAndSummarize(20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.") + accounts := config.AllEmailAccounts() + if len(accounts) == 0 { + return "", fmt.Errorf("Kein Email-Account konfiguriert") + } + if len(accounts) == 1 { + return fetchAndSummarizeAccount(accounts[0], 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.") + } + var parts []string + for _, acc := range accounts { + result, err := fetchAndSummarizeAccount(acc, 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.") + if err != nil { + slog.Error("Email-Fehler", "account", acc.Name, "fehler", err) + parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) + continue + } + parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result)) + } + return strings.Join(parts, "\n\n"), nil } -// SummarizeUnread fasst ungelesene Emails zusammen. -// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben. +// SummarizeUnread fasst ungelesene Emails für alle konfigurierten Accounts zusammen. +// Wenn email.processed_folder konfiguriert ist, werden Emails danach dorthin verschoben. func SummarizeUnread() (string, error) { - cl, err := Connect() + accounts := config.AllEmailAccounts() + if len(accounts) == 0 { + return "", fmt.Errorf("Kein Email-Account konfiguriert") + } + if len(accounts) == 1 { + return SummarizeUnreadAccount(accounts[0]) + } + + var parts []string + allEmpty := true + for _, acc := range accounts { + result, err := SummarizeUnreadAccount(acc) + if err != nil { + slog.Error("Email-Fehler", "account", acc.Name, "fehler", err) + parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) + continue + } + if result != "📭 Keine ungelesenen Emails." { + allEmpty = false + parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result)) + } + } + if allEmpty { + return "📭 Keine ungelesenen Emails.", nil + } + return strings.Join(parts, "\n\n"), nil +} + +// SummarizeUnreadAccount fasst ungelesene Emails für einen bestimmten Account zusammen. +// Wenn triage_folder konfiguriert ist, werden unwichtige Emails vorher aussortiert. +func SummarizeUnreadAccount(acc config.EmailAccount) (string, error) { + // Phase 1: Triage – Emails sortieren (eigene Verbindung) + if acc.TriageUnimportantFolder != "" || acc.TriageImportantFolder != "" { + if err := triageUnread(acc); err != nil { + slog.Warn("[Triage] fehlgeschlagen, übersprungen", "account", accountLabel(acc), "fehler", err) + } + } + + // Phase 2: Zusammenfassung der verbleibenden wichtigen Emails (frische Verbindung) + cl, err := ConnectAccount(acc) if err != nil { - return "", fmt.Errorf("Email-Verbindung: %w", err) + return "", fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err) } defer cl.Close() - processedFolder := config.Cfg.Email.ProcessedFolder - var msgs []Message var seqNums []uint32 - if processedFolder != "" { + if acc.ProcessedFolder != "" { msgs, seqNums, err = cl.FetchUnreadSeqNums() } else { msgs, err = cl.FetchUnread() } if err != nil { - return "", fmt.Errorf("Emails abrufen: %w", err) + return "", fmt.Errorf("Emails abrufen (%s): %w", accountLabel(acc), err) } if len(msgs) == 0 { return "📭 Keine ungelesenen Emails.", nil } - slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread") - result, err := summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.") + slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs), "typ", "unread") + result, err := summarizeWithLLMModel(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.", accountModel(acc)) if err != nil { return "", err } // Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben - if processedFolder != "" && len(seqNums) > 0 { - if moveErr := cl.MoveMessages(seqNums, processedFolder); moveErr != nil { - slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", processedFolder) + if acc.ProcessedFolder != "" && len(seqNums) > 0 { + if moveErr := cl.MoveMessages(seqNums, acc.ProcessedFolder); moveErr != nil { + slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", acc.ProcessedFolder) } else { - slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", processedFolder) + slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", acc.ProcessedFolder) } } return result, nil } +// TriageRecentAllAccounts klassifiziert die letzten n Emails aller Accounts manuell +// und verschiebt sie in die konfigurierten Triage-Ordner. +func TriageRecentAllAccounts(n uint32) (string, error) { + accounts := config.AllEmailAccounts() + if len(accounts) == 0 { + return "", fmt.Errorf("kein Email-Account konfiguriert") + } + + var lines []string + for _, acc := range accounts { + if acc.TriageImportantFolder == "" && acc.TriageUnimportantFolder == "" { + lines = append(lines, fmt.Sprintf("⚠️ **%s:** kein triage_important_folder / triage_unimportant_folder konfiguriert", accountLabel(acc))) + continue + } + wichtig, unwichtig, err := triageRecentAccount(acc, n) + if err != nil { + lines = append(lines, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) + continue + } + lines = append(lines, fmt.Sprintf("✅ **%s:** %d wichtig → `%s`, %d unwichtig → `%s`", + accountLabel(acc), wichtig, acc.TriageImportantFolder, unwichtig, acc.TriageUnimportantFolder)) + } + return strings.Join(lines, "\n"), nil +} + +// triageRecentAccount klassifiziert die letzten n Emails eines Accounts. +// Gibt Anzahl wichtiger und unwichtiger Emails zurück. +func triageRecentAccount(acc config.EmailAccount, n uint32) (wichtig, unwichtig int, err error) { + cl, err := ConnectAccount(acc) + if err != nil { + return 0, 0, fmt.Errorf("verbinden: %w", err) + } + defer cl.Close() + + // Ordner vorab anlegen, unabhängig davon ob Emails verschoben werden + if acc.TriageImportantFolder != "" { + if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil { + slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err) + } + } + if acc.TriageUnimportantFolder != "" { + if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil { + slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err) + } + } + + msgs, err := cl.FetchRecentForSelect(n) + if err != nil { + return 0, 0, fmt.Errorf("fetch: %w", err) + } + if len(msgs) == 0 { + return 0, 0, nil + } + + model := accountModel(acc) + var wichtigSeqNums, unwichtigSeqNums []uint32 + + slog.Info("[Triage] Manuell gestartet", "account", accountLabel(acc), "anzahl", len(msgs)) + for _, msg := range msgs { + if ClassifyImportance(msg.Message, model) { + wichtigSeqNums = append(wichtigSeqNums, msg.SeqNum) + } else { + unwichtigSeqNums = append(unwichtigSeqNums, msg.SeqNum) + } + } + + if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 { + if ensureErr := cl.EnsureFolder(acc.TriageUnimportantFolder); ensureErr != nil { + slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", ensureErr) + } + if moveErr := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); moveErr != nil { + slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "fehler", moveErr) + } + } + + if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 { + if ensureErr := cl.EnsureFolder(acc.TriageImportantFolder); ensureErr != nil { + slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", ensureErr) + } + if moveErr := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); moveErr != nil { + slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "fehler", moveErr) + } + } + + return len(wichtigSeqNums), len(unwichtigSeqNums), nil +} + +// triageUnread klassifiziert alle ungelesenen Emails eines Accounts und verschiebt +// wichtige in TriageImportantFolder und unwichtige in TriageUnimportantFolder. +// Läuft sequentiell: eine Email nach der anderen. +func triageUnread(acc config.EmailAccount) error { + cl, err := ConnectAccount(acc) + if err != nil { + return fmt.Errorf("verbinden: %w", err) + } + defer cl.Close() + + // Ordner vorab anlegen + if acc.TriageImportantFolder != "" { + cl.EnsureFolder(acc.TriageImportantFolder) + } + if acc.TriageUnimportantFolder != "" { + cl.EnsureFolder(acc.TriageUnimportantFolder) + } + + msgs, seqNums, err := cl.FetchUnreadSeqNums() + if err != nil { + return fmt.Errorf("fetch: %w", err) + } + if len(msgs) == 0 { + return nil + } + + model := accountModel(acc) + var wichtigSeqNums, unwichtigSeqNums []uint32 + + slog.Info("[Triage] Klassifizierung gestartet", "account", accountLabel(acc), "anzahl", len(msgs)) + for i, msg := range msgs { + if ClassifyImportance(msg, model) { + wichtigSeqNums = append(wichtigSeqNums, seqNums[i]) + } else { + unwichtigSeqNums = append(unwichtigSeqNums, seqNums[i]) + } + } + + if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 { + if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil { + slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageUnimportantFolder, "fehler", err) + } + if err := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); err != nil { + slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err) + } else { + slog.Info("[Triage] Unwichtige Emails verschoben", "anzahl", len(unwichtigSeqNums), "ordner", acc.TriageUnimportantFolder) + } + } + + if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 { + if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil { + slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageImportantFolder, "fehler", err) + } + if err := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); err != nil { + slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err) + } else { + slog.Info("[Triage] Wichtige Emails verschoben", "anzahl", len(wichtigSeqNums), "ordner", acc.TriageImportantFolder) + } + } + + return nil +} + +// ClassifyImportance klassifiziert eine einzelne Email als wichtig (true) oder unwichtig (false). +// Sucht zuerst ähnliche vergangene Entscheidungen in Qdrant (RAG) und gibt sie als Kontext mit. +// Im Fehlerfall oder bei unklarer Antwort wird true (wichtig) zurückgegeben – sicherer Default. +func ClassifyImportance(msg Message, model string) bool { + // RAG: ähnliche vergangene Triage-Entscheidungen als Few-Shot-Beispiele + ragQuery := fmt.Sprintf("Von: %s Betreff: %s", msg.From, msg.Subject) + examples := triage.SearchSimilar(ragQuery) + + var examplesText string + if len(examples) > 0 { + var sb strings.Builder + sb.WriteString("Ähnliche Entscheidungen aus der Vergangenheit:\n") + for _, ex := range examples { + sb.WriteString("- ") + sb.WriteString(ex.Text) + sb.WriteString("\n") + } + sb.WriteString("\n") + examplesText = sb.String() + } + + prompt := fmt.Sprintf("%sVon: %s\nBetreff: %s\n\nIst diese Email wichtig? Antworte NUR mit einem einzigen Wort: wichtig oder unwichtig.", + examplesText, msg.From, msg.Subject) + + chatClient := config.NewChatClient() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := chatClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: model, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: "Du bist ein Email-Filter. Antworte immer nur mit einem einzigen Wort: wichtig oder unwichtig."}, + {Role: openai.ChatMessageRoleUser, Content: prompt}, + }, + Temperature: 0.1, + MaxTokens: 300, + }) + if err != nil { + slog.Warn("[Triage] LLM-Fehler, Email als wichtig eingestuft", "betreff", msg.Subject, "fehler", err) + return true + } + if len(resp.Choices) == 0 { + return true + } + + raw := resp.Choices[0].Message.Content + // Reasoning-Modelle (z.B. Qwen3) geben Antwort nach -Tag aus + if idx := strings.LastIndex(raw, ""); idx >= 0 { + raw = raw[idx+len(""):] + } + answer := strings.ToLower(strings.TrimSpace(raw)) + isImportant := !strings.Contains(answer, "unwichtig") + slog.Info("[Triage] Email klassifiziert", + "betreff", msg.Subject, + "von", msg.From, + "wichtig", isImportant, + "rag_beispiele", len(examples), + "antwort", answer, + ) + + // Entscheidung für künftiges Lernen in Qdrant speichern + if err := triage.StoreDecision(msg.Subject, msg.From, isImportant); err != nil { + slog.Warn("[Triage] Entscheidung nicht gespeichert", "fehler", err) + } + + return isImportant +} + // ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines. func ExtractReminders() (string, error) { - return fetchAndSummarize(30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.") + accounts := config.AllEmailAccounts() + if len(accounts) == 0 { + return "", fmt.Errorf("Kein Email-Account konfiguriert") + } + if len(accounts) == 1 { + return fetchAndSummarizeAccount(accounts[0], 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.") + } + var parts []string + for _, acc := range accounts { + result, err := fetchAndSummarizeAccount(acc, 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.") + if err != nil { + slog.Error("Email-Fehler", "account", acc.Name, "fehler", err) + parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err)) + continue + } + parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result)) + } + return strings.Join(parts, "\n\n"), nil +} + +// MoveUnread verschiebt alle ungelesenen Emails eines Accounts in den Zielordner. +// Gibt die Anzahl verschobener Emails zurück. +func MoveUnread(acc config.EmailAccount, destFolder string) (int, error) { + cl, err := ConnectAccount(acc) + if err != nil { + return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err) + } + defer cl.Close() + + _, seqNums, err := cl.FetchUnreadSeqNums() + if err != nil { + return 0, fmt.Errorf("Emails abrufen: %w", err) + } + if len(seqNums) == 0 { + return 0, nil + } + + if err := cl.MoveMessages(seqNums, destFolder); err != nil { + return 0, fmt.Errorf("Verschieben nach %s: %w", destFolder, err) + } + return len(seqNums), nil +} + +// AccountSelectMessages enthält ungelesene Emails eines Accounts für die Discord-Auswahl. +type AccountSelectMessages struct { + Account config.EmailAccount + AccIndex int + Messages []SelectMessage +} + +// FetchUnreadForSelectAllAccounts holt ungelesene Emails aller Accounts für die Discord-Auswahl. +func FetchUnreadForSelectAllAccounts() ([]AccountSelectMessages, error) { + accounts := config.AllEmailAccounts() + var result []AccountSelectMessages + for i, acc := range accounts { + cl, err := ConnectAccount(acc) + if err != nil { + return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err) + } + msgs, err := cl.FetchUnreadForSelect() + cl.Close() + if err != nil { + return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err) + } + result = append(result, AccountSelectMessages{ + Account: acc, + AccIndex: i, + Messages: msgs, + }) + } + return result, nil +} + +// FetchRecentForSelectAllAccounts holt die letzten n Emails aller Accounts für die Discord-Auswahl. +func FetchRecentForSelectAllAccounts(n uint32) ([]AccountSelectMessages, error) { + accounts := config.AllEmailAccounts() + var result []AccountSelectMessages + for i, acc := range accounts { + cl, err := ConnectAccount(acc) + if err != nil { + return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err) + } + msgs, err := cl.FetchRecentForSelect(n) + cl.Close() + if err != nil { + return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err) + } + result = append(result, AccountSelectMessages{ + Account: acc, + AccIndex: i, + Messages: msgs, + }) + } + return result, nil +} + +// MoveOldEmailsAllAccounts verschiebt alle Emails aller Accounts, die älter als olderThanDays Tage sind, nach destFolder. +// Gibt die Gesamtanzahl verschobener Emails zurück. +func MoveOldEmailsAllAccounts(destFolder string, olderThanDays int) (int, error) { + accounts := config.AllEmailAccounts() + total := 0 + for _, acc := range accounts { + cl, err := ConnectAccount(acc) + if err != nil { + return total, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err) + } + n, err := cl.MoveOldMessages(acc.Folder, destFolder, olderThanDays) + cl.Close() + if err != nil { + return total, fmt.Errorf("Verschieben %s: %w", accountLabel(acc), err) + } + total += n + } + return total, nil +} + +// MoveSpecificUnread verschiebt spezifische Emails (per Sequenznummer) eines Accounts in den Zielordner. +func MoveSpecificUnread(accIndex int, seqNums []uint32, destFolder string) (int, error) { + accounts := config.AllEmailAccounts() + if accIndex < 0 || accIndex >= len(accounts) { + return 0, fmt.Errorf("ungültiger Account-Index %d", accIndex) + } + acc := accounts[accIndex] + cl, err := ConnectAccount(acc) + if err != nil { + return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err) + } + defer cl.Close() + if err := cl.MoveSpecificMessages(seqNums, destFolder); err != nil { + return 0, err + } + return len(seqNums), nil +} + +// CleanupArchiveFolders löscht abgelaufene Emails aus allen konfigurierten Archivordnern. +// Gibt eine menschenlesbare Zusammenfassung zurück. +func CleanupArchiveFolders() (string, error) { + accounts := config.AllEmailAccounts() + var lines []string + var errs []string + total := 0 + + for _, acc := range accounts { + for _, af := range acc.ArchiveFolders { + if af.RetentionDays <= 0 { + continue + } + cl, err := ConnectAccount(acc) + if err != nil { + errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err)) + continue + } + n, err := cl.CleanupOldEmails(af.IMAPFolder, af.RetentionDays) + cl.Close() + if err != nil { + errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err)) + continue + } + if n > 0 { + lines = append(lines, fmt.Sprintf("🗑️ %s/%s: %d Email(s) gelöscht (älter als %d Tage)", accountLabel(acc), af.Name, n, af.RetentionDays)) + total += n + } + } + } + + var result string + if len(lines) == 0 && len(errs) == 0 { + result = "Kein Aufräumen notwendig." + } else { + result = strings.Join(lines, "\n") + } + + var combinedErr error + if len(errs) > 0 { + combinedErr = fmt.Errorf("%s", strings.Join(errs, "; ")) + } + slog.Info("Archiv-Aufräumen abgeschlossen", "gelöscht", total, "fehler", len(errs)) + return result, combinedErr } // SummarizeMessages fasst eine übergebene Liste von Nachrichten zusammen (für Tests ohne IMAP). @@ -72,8 +522,8 @@ func SummarizeMessages(msgs []Message, instruction string) (string, error) { return summarizeWithLLM(msgs, instruction) } -func fetchAndSummarize(n uint32, instruction string) (string, error) { - cl, err := Connect() +func fetchAndSummarizeAccount(acc config.EmailAccount, n uint32, instruction string) (string, error) { + cl, err := ConnectAccount(acc) if err != nil { return "", fmt.Errorf("Email-Verbindung: %w", err) } @@ -87,12 +537,27 @@ func fetchAndSummarize(n uint32, instruction string) (string, error) { return "📭 Keine Emails gefunden.", nil } - slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs)) - return summarizeWithLLM(msgs, instruction) + slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs)) + return summarizeWithLLMModel(msgs, instruction, accountModel(acc)) } -// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück. -// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist. +// accountLabel gibt einen lesbaren Namen für einen Account zurück. +func accountLabel(acc config.EmailAccount) string { + if acc.Name != "" { + return acc.Name + } + return acc.User +} + +// accountModel gibt das konfigurierte LLM-Modell für einen Account zurück. +func accountModel(acc config.EmailAccount) string { + if acc.Model != "" { + return acc.Model + } + return config.Cfg.Chat.Model +} + +// emailModel gibt das konfigurierte Modell für den Legacy-Account zurück. func emailModel() string { if config.Cfg.Email.Model != "" { return config.Cfg.Email.Model @@ -110,8 +575,11 @@ func formatEmailList(msgs []Message) string { } func summarizeWithLLM(msgs []Message, instruction string) (string, error) { + return summarizeWithLLMModel(msgs, instruction, emailModel()) +} + +func summarizeWithLLMModel(msgs []Message, instruction, model string) (string, error) { emailList := formatEmailList(msgs) - model := emailModel() chatClient := config.NewChatClient() ctx := context.Background() diff --git a/internal/agents/tool/email/summary_test.go b/internal/agents/tool/email/summary_test.go index 6c8426f..c2d4aff 100644 --- a/internal/agents/tool/email/summary_test.go +++ b/internal/agents/tool/email/summary_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "my-brain-importer/internal/config" ) var testMessages = []Message{ @@ -70,3 +72,46 @@ func TestMessage_DateFormat(t *testing.T) { t.Errorf("Datumsformat ungültig: %v", err) } } + +func TestAccountLabel_WithName(t *testing.T) { + acc := config.EmailAccount{Name: "Privat", User: "user@example.de"} + if got := accountLabel(acc); got != "Privat" { + t.Errorf("accountLabel: erwartet %q, got %q", "Privat", got) + } +} + +func TestAccountLabel_FallsBackToUser(t *testing.T) { + acc := config.EmailAccount{User: "user@example.de"} + if got := accountLabel(acc); got != "user@example.de" { + t.Errorf("accountLabel: erwartet %q, got %q", "user@example.de", got) + } +} + +func TestAccountModel_WithModel(t *testing.T) { + acc := config.EmailAccount{Model: "custom-model"} + if got := accountModel(acc); got != "custom-model" { + t.Errorf("accountModel: erwartet %q, got %q", "custom-model", got) + } +} + +func TestAccountModel_FallsBackToChatModel(t *testing.T) { + orig := config.Cfg + defer func() { config.Cfg = orig }() + config.Cfg.Chat.Model = "default-model" + + acc := config.EmailAccount{} // kein Model gesetzt + if got := accountModel(acc); got != "default-model" { + t.Errorf("accountModel: erwartet chat.model %q, got %q", "default-model", got) + } +} + +func TestSummarizeUnread_NoAccountsConfigured(t *testing.T) { + orig := config.Cfg + defer func() { config.Cfg = orig }() + config.Cfg = config.Config{} // leere Config, kein Email-Account + + _, err := SummarizeUnread() + if err == nil { + t.Error("erwartet Fehler wenn kein Account konfiguriert") + } +} diff --git a/internal/agents/tool/rss/watcher.go b/internal/agents/tool/rss/watcher.go new file mode 100644 index 0000000..6d6f5b1 --- /dev/null +++ b/internal/agents/tool/rss/watcher.go @@ -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) + } + } +} diff --git a/internal/brain/ask.go b/internal/brain/ask.go index 239181f..662c01d 100755 --- a/internal/brain/ask.go +++ b/internal/brain/ask.go @@ -41,7 +41,8 @@ func AskQuery(question string, history []agents.HistoryMessage) (string, []Knowl contextText := buildContext(chunks) - systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. + coreMemory := LoadCoreMemory() + systemPromptBase := `Du bist ein hilfreicher persönlicher Assistent. Beantworte Fragen primär anhand der bereitgestellten Informationen aus der Wissensdatenbank. Ergänze fehlende Details mit deinem eigenen Wissen, kennzeichne dies aber klar mit "Aus meinem Wissen:". @@ -50,6 +51,10 @@ WICHTIGE REGELN: - Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es deutlich - Antworte auf Deutsch - Sei präzise und direkt` + systemPrompt := systemPromptBase + if coreMemory != "" { + systemPrompt = systemPromptBase + "\n\n## Fakten über den Nutzer:\n" + coreMemory + } userPrompt := fmt.Sprintf(`Hier sind die relevanten Informationen aus meiner Wissensdatenbank: diff --git a/internal/brain/core_memory.go b/internal/brain/core_memory.go new file mode 100644 index 0000000..44847e6 --- /dev/null +++ b/internal/brain/core_memory.go @@ -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 ` um Fakten hinzuzufügen." + } + return fmt.Sprintf("🧠 **Kerngedächtnis:**\n```\n%s\n```", content) +} diff --git a/internal/brain/ingest.go b/internal/brain/ingest.go index 6f6ace1..2b4bcf8 100755 --- a/internal/brain/ingest.go +++ b/internal/brain/ingest.go @@ -256,3 +256,30 @@ func IngestChatMessage(text, author, source string) error { } func boolPtr(b bool) *bool { return &b } + +// IngestText speichert einen beliebigen Text mit Quelle und Typ in Qdrant. +// Verwendet die gleiche Chunking-Logik wie der Markdown-Ingest. +func IngestText(text, source, docType string) error { + ctx := context.Background() + ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey) + + embClient := config.NewEmbeddingClient() + conn := config.NewQdrantConn() + defer conn.Close() + + ensureCollection(ctx, pb.NewCollectionsClient(conn)) + pointsClient := pb.NewPointsClient(conn) + + var chunks []chunk + for _, part := range splitLongSection(text) { + part = strings.TrimSpace(part) + if len(part) < 20 { + continue + } + chunks = append(chunks, chunk{Text: part, Source: source, Type: docType}) + } + if len(chunks) == 0 { + return nil + } + return ingestChunks(ctx, embClient, pointsClient, chunks) +} diff --git a/internal/brain/ingest_email.go b/internal/brain/ingest_email.go new file mode 100644 index 0000000..29778d6 --- /dev/null +++ b/internal/brain/ingest_email.go @@ -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() +} diff --git a/internal/brain/ingest_pdf.go b/internal/brain/ingest_pdf.go new file mode 100644 index 0000000..c925946 --- /dev/null +++ b/internal/brain/ingest_pdf.go @@ -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 +} diff --git a/internal/brain/ingest_url.go b/internal/brain/ingest_url.go new file mode 100644 index 0000000..ba2942c --- /dev/null +++ b/internal/brain/ingest_url.go @@ -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) + } +} diff --git a/internal/brain/knowledge.go b/internal/brain/knowledge.go new file mode 100644 index 0000000..32b96e0 --- /dev/null +++ b/internal/brain/knowledge.go @@ -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 } diff --git a/internal/config/config.go b/internal/config/config.go index fd46ac9..ddfa8c0 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,30 @@ import ( "gopkg.in/yaml.v3" ) +// ArchiveFolder beschreibt einen IMAP-Archivordner mit optionaler Aufbewahrungsdauer. +type ArchiveFolder struct { + Name string `yaml:"name"` // Anzeigename (z.B. "5Jahre") + IMAPFolder string `yaml:"imap_folder"` // Echter IMAP-Ordnername (z.B. "5Jahre") + RetentionDays int `yaml:"retention_days"` // 0 = dauerhaft behalten +} + +// EmailAccount beschreibt einen einzelnen IMAP-Account. +type EmailAccount struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + TLS bool `yaml:"tls"` + StartTLS bool `yaml:"starttls"` + Folder string `yaml:"folder"` + ProcessedFolder string `yaml:"processed_folder"` + Model string `yaml:"model"` + ArchiveFolders []ArchiveFolder `yaml:"archive_folders"` + TriageImportantFolder string `yaml:"triage_important_folder"` // Ordner für wichtige Emails (leer = in INBOX lassen) + TriageUnimportantFolder string `yaml:"triage_unimportant_folder"` // Ordner für unwichtige Emails (leer = kein Triage) +} + type Config struct { Qdrant struct { Host string `yaml:"host"` @@ -32,22 +56,30 @@ type Config struct { } `yaml:"chat"` Discord struct { - Token string `yaml:"token"` - GuildID string `yaml:"guild_id"` + Token string `yaml:"token"` + GuildID string `yaml:"guild_id"` + AllowedUsers []string `yaml:"allowed_users"` // Wenn gesetzt, dürfen nur diese User-IDs den Bot nutzen } `yaml:"discord"` + // Email ist der Legacy-Block für einen einzelnen Account. Email struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - User string `yaml:"user"` - Password string `yaml:"password"` - TLS bool `yaml:"tls"` - StartTLS bool `yaml:"starttls"` - Folder string `yaml:"folder"` - ProcessedFolder string `yaml:"processed_folder"` // Zielordner nach Zusammenfassung (leer = kein Verschieben) - Model string `yaml:"model"` // Optional: überschreibt chat.model für Email-Zusammenfassungen + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + TLS bool `yaml:"tls"` + StartTLS bool `yaml:"starttls"` + Folder string `yaml:"folder"` + ProcessedFolder string `yaml:"processed_folder"` + Model string `yaml:"model"` + ArchiveFolders []ArchiveFolder `yaml:"archive_folders"` + TriageImportantFolder string `yaml:"triage_important_folder"` + TriageUnimportantFolder string `yaml:"triage_unimportant_folder"` } `yaml:"email"` + // EmailAccounts ermöglicht mehrere IMAP-Accounts. Hat Vorrang vor email:. + EmailAccounts []EmailAccount `yaml:"email_accounts"` + Tasks struct { StorePath string `yaml:"store_path"` } `yaml:"tasks"` @@ -56,15 +88,52 @@ type Config struct { ChannelID string `yaml:"channel_id"` EmailIntervalMin int `yaml:"email_interval_min"` TaskReminderHour int `yaml:"task_reminder_hour"` + CleanupHour int `yaml:"cleanup_hour"` // Uhrzeit für tägliches Archiv-Aufräumen (Standard: 2) + IngestHour int `yaml:"ingest_hour"` // Uhrzeit für nächtlichen Email-Ingest (Standard: 23, 0 = deaktiviert) } `yaml:"daemon"` BrainRoot string `yaml:"brain_root"` TopK uint64 `yaml:"top_k"` ScoreThreshold float32 `yaml:"score_threshold"` + + // RSSFeeds definiert RSS-Feeds die automatisch überwacht werden. + RSSFeeds []RSSFeed `yaml:"rss_feeds"` +} + +// RSSFeed beschreibt einen RSS-Feed mit Polling-Intervall. +type RSSFeed struct { + URL string `yaml:"url"` + IntervalHours int `yaml:"interval_hours"` // 0 = Standard 24h } var Cfg Config +// AllEmailAccounts gibt alle konfigurierten Email-Accounts zurück. +// Wenn email_accounts konfiguriert ist, hat das Vorrang vor dem Legacy-email:-Block. +func AllEmailAccounts() []EmailAccount { + if len(Cfg.EmailAccounts) > 0 { + return Cfg.EmailAccounts + } + if Cfg.Email.Host == "" { + return nil + } + return []EmailAccount{{ + Name: "Email", + Host: Cfg.Email.Host, + Port: Cfg.Email.Port, + User: Cfg.Email.User, + Password: Cfg.Email.Password, + TLS: Cfg.Email.TLS, + StartTLS: Cfg.Email.StartTLS, + Folder: Cfg.Email.Folder, + ProcessedFolder: Cfg.Email.ProcessedFolder, + Model: Cfg.Email.Model, + ArchiveFolders: Cfg.Email.ArchiveFolders, + TriageImportantFolder: Cfg.Email.TriageImportantFolder, + TriageUnimportantFolder: Cfg.Email.TriageUnimportantFolder, + }} +} + // NewQdrantConn öffnet eine gRPC-Verbindung zur Qdrant-Instanz. // Der Aufrufer ist verantwortlich für conn.Close(). func NewQdrantConn() *grpc.ClientConn { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d3afaf2 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/diag/diag.go b/internal/diag/diag.go index 121137f..310b186 100644 --- a/internal/diag/diag.go +++ b/internal/diag/diag.go @@ -49,11 +49,18 @@ func RunAll() (results []Result, allOK bool) { check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg) } - // IMAP - if cfg.Email.Host != "" { - imapAddr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port) + // IMAP – alle konfigurierten Accounts prüfen + for _, acc := range config.AllEmailAccounts() { + if acc.Host == "" { + continue + } + imapAddr := fmt.Sprintf("%s:%d", acc.Host, acc.Port) + label := acc.Name + if label == "" { + label = acc.User + } ok, msg = tcpCheck(imapAddr) - check("IMAP ("+imapAddr+")", ok, msg) + check("IMAP "+label+" ("+imapAddr+")", ok, msg) } return results, allOK diff --git a/internal/triage/triage.go b/internal/triage/triage.go new file mode 100644 index 0000000..5dff942 --- /dev/null +++ b/internal/triage/triage.go @@ -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]) +} diff --git a/tasks.json b/tasks.json index cf94e5d..7ff7c4a 100644 --- a/tasks.json +++ b/tasks.json @@ -2,13 +2,15 @@ { "id": "1773950110942000154", "text": "Synology DSM Update durchfuehren", - "done": false, - "created_at": "2026-03-19T20:55:10.942001964+01:00" + "done": true, + "created_at": "2026-03-19T20:55:10.942001964+01:00", + "done_at": "2026-03-20T20:56:51.054077698+01:00" }, { "id": "1773950110942351936", "text": "Zahnarzt Termin bestaetigen", - "done": false, - "created_at": "2026-03-19T20:55:10.942353012+01:00" + "done": true, + "created_at": "2026-03-19T20:55:10.942353012+01:00", + "done_at": "2026-03-20T20:58:27.785644744+01:00" } ] \ No newline at end of file