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