Files
ai-agent/doc/architecture.md
2026-03-24 08:05:24 +01:00

8.9 KiB
Raw Blame History

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().

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:

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.*:

  • storebrain.IngestChatMessage()
  • ingestbrain.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 (brainemailtriage).

  • 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
    │  1. CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build (lokal)
    │  2. sshpass + scp (Binary + Dockerfile + docker-compose.yml + config.yml)
    │  3. docker compose up -d --build (remote)
    ▼
Home-Server (192.168.1.118)
    ├── Docker: brain-bot Container (alpine:3.21 + Binary)
    │     Volumes: config.yml, tasks.json, brain_data/
    │
    ├── 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