Files
ai-agent/doc/architecture.md
Christoph K. 905981cd1e zwischenstand
2026-03-20 23:24:56 +01:00

235 lines
8.8 KiB
Markdown
Raw Blame History

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