- TypeScript 99.3%
- Dockerfile 0.3%
- JavaScript 0.2%
- CSS 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| data/samples | ||
| drizzle | ||
| scripts | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| drizzle.config.ts | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| postcss.config.mjs | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
quackAnal
Private, selbst gehostete Webanwendung, mit der ein privater Discord-Channel mit hunderttausenden Nachrichten durchsucht und ausgewertet werden kann.
Die Anwendung ist ab Werk gesperrt: Ohne konfigurierte OIDC-Anmeldung und ohne Allowlist gibt es keinen Zugriff. Alle angemeldeten und freigeschalteten Personen sehen denselben importierten Bestand.
Keine Rechtsberatung. Dieses Projekt stellt Werkzeuge bereit, keine Rechtskonformität. Ob die Verarbeitung von Chatnachrichten zulässig ist, hängt von Rechtsgrundlage, Verhältnissen, Löschfristen und Informationspflichten ab und ist vom Betreiber zu bewerten. Siehe Datenschutz und Rechtsfragen.
Inhalt
- Schnellstart
- Umgebungsvariablen
- Authentifizierung mit Pocket ID (OIDC)
- Ohne OIDC ansehen (Entwicklungs-Bypass)
- Discord-Daten importieren
- Embeddings und semantische Suche
- Hintergrundverarbeitung
- Funktionsumfang der Oberfläche
- Architektur und Datenmodell
- Skalierung auf 500.000 Nachrichten
- Sicherheit
- Datenschutz und Rechtsfragen
- Tests, Linting, Typechecking
- Bekannte Einschränkungen
- Weiterführend
Schnellstart
Voraussetzungen: Node.js ≥ 20.11 und Docker (für PostgreSQL).
git clone <repository> quackanal
cd quackanal
# 1. Abhängigkeiten installieren
npm install
# 2. Konfiguration anlegen (enthält keine echten Geheimnisse)
cp .env.example .env.local
$EDITOR .env.local # OIDC_* und Allowlist setzen, mindestens:
# OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,
# OIDC_ALLOWED_DOMAINS oder OIDC_ALLOWED_EMAILS
# 3. Datenbank starten (PostgreSQL 17 + pgvector)
docker compose up -d db
# 4. Erweiterungen aktivieren und Migrationen ausführen
npm run db:migrate
# 5. Web-Oberfläche starten
npm run dev # http://localhost:3000
# 6. Hintergrundprozesse starten (zweites Terminal)
npm run worker
Hinweis:
npm run db:migrate,npm run workerundnpm run sample:importladen.env.localselbst ein.tsxliest.env-Dateien nicht von allein – ohne diesen Schritt würde der Migrationslauf mit „Fehlende Umgebungsvariable DATABASE_URL“ abbrechen. Die Web-Oberfläche (next dev) lädt sie wie gewohnt.
Optional: Beispieldaten erzeugen und importieren, ohne Discord zu bemühen.
npm run sample:generate # Beispieldatei (erfundene Inhalte)
npm run sample:import -- data/samples/discord-export-sample.json
npm run worker # Abschnitte + Embeddings erzeugen
Der komplette Stack (Datenbank + Web + Worker) läuft auch in Containern:
docker compose --profile app up -d --build
Zur Konfiguration des IdP muss Pocket ID erreichbar sein. Für einen ersten Test genügt
OIDC_ALLOW_ALL=truezusammen mit einem beliebigen OIDC-Provider (z. B. Pocket ID). Diese Option ist für den Normalbetrieb nicht vorgesehen und wird zusammen mit Mock-KI-Providern inNODE_ENV=productionabgelehnt.
Umgebungsvariablen
Vollständige Liste mit Erklärungen: .env.example.
Basis
| Variable | Pflicht | Bedeutung |
|---|---|---|
DATABASE_URL |
ja | PostgreSQL-Verbindung |
APP_URL |
ja (Default http://localhost:3000) |
Öffentliche URL; bestimmt OIDC-Redirect und CSRF-Origin-Prüfung |
LOG_LEVEL |
nein | debug | info | warn | error |
TRUST_PROXY_HEADERS |
nein | true hinter Reverse Proxy, damit X-Forwarded-For für das Rate-Limit genutzt wird |
IMPORT_MAX_UPLOAD_BYTES |
nein | Maximale Dateigröße beim Import (Default 512 MB) |
Authentifizierung
| Variable | Pflicht | Bedeutung |
|---|---|---|
OIDC_ISSUER |
ja | Issuer bzw. Discovery-URL des Providers |
OIDC_CLIENT_ID |
ja | Client-ID |
OIDC_CLIENT_SECRET |
ja | Client-Secret (nur serverseitig) |
OIDC_REDIRECT_URI |
nein | Default ${APP_URL}/api/auth/callback |
OIDC_SCOPES |
nein | Default openid profile email |
OIDC_ALLOWED_DOMAINS |
eine von | Erlaubte E-Mail-Domains, kommagetrennt |
OIDC_ALLOWED_EMAILS |
eine von | Erlaubte Einzeladressen, kommagetrennt |
OIDC_ALLOWED_GROUP_CLAIM / OIDC_ALLOWED_GROUP_VALUE |
optional | Zugang über eine Pocket-ID-Gruppe |
OIDC_ALLOW_ALL |
nein | true = jeder angemeldete Account; nur für Entwicklung |
SESSION_TTL_HOURS |
nein | Session-Lebensdauer (Default 24) |
AUTH_BYPASS_ENABLED |
nein | true = Anmeldung ohne OIDC (nur Entwicklung, in Produktion ignoriert) |
AUTH_BYPASS_EMAIL / AUTH_BYPASS_NAME / AUTH_BYPASS_ADMIN |
nein | Identität des Entwicklungsnutzers |
Fehlt jede Allowlist, startet die App nicht mit einer erklärenden Meldung.
KI-Provider
| Variable | Default | Bedeutung |
|---|---|---|
EMBEDDING_PROVIDER |
mock |
openai | ollama | mock |
EMBEDDING_MODEL |
text-embedding-3-small |
Modellname (frei wählbar) |
EMBEDDING_DIMENSIONS |
1536 |
Muss zur DB-Spalte passen |
EMBEDDING_DAILY_TOKEN_BUDGET |
2000000 |
Kostengrenze pro Tag |
CHAT_PROVIDER |
mock |
openai | ollama | mock |
CHAT_MODEL |
gpt-4o-mini |
Modellname |
OPENAI_API_KEY |
– | nur serverseitig; wird nie an den Browser ausgeliefert |
OPENAI_BASE_URL |
– | kompatible Gateways (LiteLLM, vLLM, …) |
OLLAMA_BASE_URL |
http://localhost:11434 |
lokale Alternative |
Ohne Konfiguration bleiben Stichwortsuche und Statistiken vollständig funktionsfähig. Externe Anbieter werden nie unbeabsichtigt verwendet: Fehlt der Key, meldet die Oberfläche einen erklärenden Hinweis, statt stillschweigend zurückzufallen.
Discord-Bot (optional)
Siehe Import. Nur für neue Nachrichten.
Authentifizierung mit Pocket ID (OIDC)
1. Client in Pocket ID anlegen
In Pocket ID: OIDC Clients → Create Client
| Feld | Wert |
|---|---|
| Name | quackanal |
| Client type | Public oder Confidential (beides unterstützt) |
| Redirect URIs | https://<deine-domain>/api/auth/callback |
| Post logout redirect URIs | https://<deine-domain>/login |
| Scopes | openid, profile, email |
| PKCE | enabled (die Anwendung verwendet PKCE verpflichtend) |
Notizen:
- Für den lokalen Test mit
http://localhost:3000genügthttp://localhost:3000/api/auth/callback. OIDC_CLIENT_SECRETwird nur benötigt, wenn der Client als Confidential angelegt wurde. Es verlässt den Server nie.- In den ID-Token-Claims müssen enthalten sein:
sub(Pflicht)emailundemail_verified(Pflicht für die Allowlist-Prüfung)nameoderpreferred_username(optional, Anzeigename)- Gruppen: konfigurierbar über
OIDC_ALLOWED_GROUP_CLAIM, Standardgroups
2. Gruppe anlegen (empfohlen)
Lege eine Gruppe an (z. B. quackanal-leser), weise die berechtigten Personen
zu und setze:
OIDC_ALLOWED_GROUP_CLAIM=groups
OIDC_ALLOWED_GROUP_VALUE=quackanal-leser
Alternativ pro Domain: OIDC_ALLOWED_DOMAINS=example.com.
3. Ersten Administrator einrichten
Beim ersten Login wird automatisch ein Konto mit Rolle viewer angelegt. Für den
Import wird admin benötigt. Es gibt keinen selbstregistrierenden Admin:
-
Einmalig in der Datenbank ausführen:
UPDATE users SET role = 'admin' WHERE email = 'du@example.com'; -
Ab sofort kann das Konto unter Administration → Angemeldete Personen die Rolle
viewer/adminvergeben, Konten deaktivieren und die Allowlist pflegen.
4. Ablauf des Logins
/api/auth/loginerzeugtstate,nonceund einen PKCE-Verifier, legt diese kurzlebig in der Datenbank ab und leitet zum Provider weiter./api/auth/callbacktauscht den Code ein, prüftstate/nonceund entscheidet anhand der Allowlist über den Zugang.- Der Browser erhält ein HTTP-only, SameSite=Lax Cookie (in Produktion
zusätzlich
Secure). In der Datenbank liegt nursha256(Geheimnis)– ein Datenbank-Dump allein reicht nicht für eine Anmeldung. - Alle Seiten, Server Actions und API-Routen prüfen die Session serverseitig. Ohne Session gibt es keinen Inhalt.
Wenn OIDC fehlt
Die Anwendung zeigt eine Konfigurationsanleitung und bleibt gesperrt. Es gibt
bewusst keinen development-only Bypass. Für automatisierte Tests wird eine
Session direkt in der Testdatenbank erzeugt (tests/e2e/fixtures.ts).
Ohne OIDC ansehen (Entwicklungs-Bypass)
Wenn du dir nur die Oberfläche ansehen willst, ohne Pocket ID einzurichten:
# in .env.local (oder .env) einfügen:
AUTH_BYPASS_ENABLED=true
npm run dev
Danach ist /dashboard direkt erreichbar – ohne Login, ohne OIDC-Client, ohne
Allowlist. Im Terminal genügt dafür die Datenbank (docker compose up -d db,
npm run db:migrate); OIDC wird nicht mehr benötigt.
Was dabei passiert
| Aspekt | Verhalten |
|---|---|
| Nutzer | Fester Entwicklungsnutzer dev@localhost mit Rolle admin (konfigurierbar) |
| Anmeldung | Vollständig übersprungen; es wird keine Session und kein Cookie erzeugt |
| Banner | Dauerhaft sichtbare Warnung oben in jeder Seite |
| Log | Beim ersten Zugriff erscheint auth.bypass.enabled im Log |
| Rechte | Bei AUTH_BYPASS_ADMIN=false nur Lese- und Suchfunktionen |
Sicherheitsregeln
Der Bypass ist so gebaut, dass er nicht versehentlich in einen Produktivbetrieb gerät:
- In
NODE_ENV=productionwird die Variable ignoriert. Die Anmeldung bleibt aktiv – auch wenn jemandAUTH_BYPASS_ENABLED=truemitnimmt. Das ist mit Tests abgesichert (tests/auth/bypass.test.ts). getAuthEnv()weicht in Produktion nicht auf die Bypass-Konfiguration aus: Issuer, Client und Allowlist werden weiterhin zwingend verlangt.- Der Bypass kann sich nicht in eine dauerhafte Sitzung umwandeln – es gibt bewusst kein Cookie.
- Es wird trotzdem eine echte Zeile in
usersangelegt, damit Fremdschlüssel (created_by,updated_by,topic_summaries) gültig bleiben. - Ist der Bypass inaktiv, wird die Datenbank nicht angefasst
(
tests/auth/bypass-db-isolation.test.ts).
Wenn die Datenbank fehlt
Beim Start ohne laufendes PostgreSQL landest du nicht auf einer
kryptischen Fehlerseite, sondern auf /nicht-verfuegbar mit einer Checkliste.
Alle geschützten Seiten leiten dorthin um – der Zugriff bleibt gesperrt, es
werden keine Daten ausgegeben.
Ausschalten
AUTH_BYPASS_ENABLED=false
Bei aktivem Bypass ist /login weiterhin erreichbar und erklärt, dass keine
Anmeldung nötig ist.
Der Bypass ersetzt nur die Anmeldung. Für die Oberfläche mit Daten (Dashboard, Suche, Import) wird weiterhin PostgreSQL benötigt.
Discord-Daten importieren
Dateien erzeugen
In Discord: Channel öffnen → Nachrichten teilen → Nachrichtenhistorie exportieren → JSON oder CSV. Alternativ mit DiscordChatExporter:
DiscordChatExporter.exe -f Json -o export.json "<Channel-ID>"
DiscordChatExporter.exe -f Jsonl -o export.jsonl "<Channel-ID>"
DiscordChatExporter.exe -f Csv -o export.csv "<Channel-ID>"
Discord erlaubt das Exportieren nur für Channels, in denen die ausführende Person Zugriff hat. Für Kanäle ohne Leseberechtigung ist der Export nicht möglich.
Unterstützte Formate
Die Format-Erkennung (src/import/adapters.ts) unterscheidet drei Adapter:
| Format | Erkennung | Channel-Angabe |
|---|---|---|
dce-json |
JSON-Objekt mit "messages": [ |
in der Datei (guild/channel) |
dce-ndjson |
eine Nachricht pro Zeile | pro Zeile oder aus dem Formular |
dce-csv |
Kopfzeile Author,AuthorID,Timestamp,Date,Content |
muss im Formular angegeben werden |
Beispieldateien liegen in data/samples/ (frei erfundene Inhalte)
und lassen sich neu erzeugen:
npm run sample:generate
npm run sample:generate -- --format ndjson
npm run sample:generate -- --format csv
Bei unbekannten Formaten bricht der Import mit einer Meldung ab, die die drei unterstützten Formate und ihre Erkennungsmerkmale nennt – es wird nicht stillschweigend eine leere Datei importiert.
Import über die Oberfläche
- Anmelden, Konto muss
adminsein. - Import & Verarbeitung → Datei auswählen. Für CSV/JSONL zusätzlich Channel-ID und -Name angeben.
- Optionen: gelöschte Nachrichten überspringen, Nachrichten ohne Text überspringen, Nur Vorschau.
- Die Vorschau zeigt Format, gelesene Datensätze und die ersten Nachrichten.
- Der eigentliche Import läuft als Hintergrundauftrag; Fortschritt, Fehler und Wiederholbarkeit sind unter Import & Verarbeitung sichtbar.
Alternativ auf der Kommandozeile:
npm run sample:import -- data/samples/discord-export-sample.json
npm run sample:import -- --channel-id 900000000000000000 --channel-name allgemein export.csv
Speicherverhalten und Deduplizierung
- Streaming: Der JSON-Scanner (
src/import/json-stream.ts) liest die Datei in Chunks und gibtmessages-Elemente einzeln aus. Ein 300-MB-Export belegt dadurch nur wenige MB zusätzlichen Speicher. CSV wird genauso zeilenweise gelesen (inkl. mehrzeiliger, kommahaltiger Felder). - Deduplizierung über
messages.discord_id(UNIQUE):- Nachricht existiert nicht → eingefügt (
neu) - Inhalt identisch → nichts geschrieben, gezählt als
übersprungen - Inhalt geändert → aktualisiert,
is_edited = true, Embeddings werden alsstalemarkiert
- Nachricht existiert nicht → eingefügt (
- Wiederaufnehmbarkeit: Ein erneut gestarteter Import ist sicher, weil alle
Datensätze idempotent verarbeitet werden.
import_runsprotokolliert jeden Lauf;import_errorshält Zeilennummer/Datensatz-ID und Grund fest – ohne Nachrichtentexte. - Ohne Discord-ID (manche CSV-Exporte) wird eine deterministische
synthetische ID aus Kanal, Autor, Zeitstempel und Inhalts-Hash erzeugt. Damit
bleibt auch dort die Deduplizierung erhalten;
id_source = 'synthetic'macht das transparent.
Discord-Bot (optional)
Zweck: neue Nachrichten in Echtzeit übernehmen.
DISCORD_BOT_TOKEN=<Bot-Token>
DISCORD_GUILD_ID=<Server-ID>
DISCORD_CHANNEL_IDS=1111111111111111111,2222222222222222222
DISCORD_SYNC_INTERVAL_MINUTES=60
Benötigte Discord-Berechtigungen:
| Berechtigung | Zweck | erforderlich |
|---|---|---|
View Channel |
Channel sehen | ja |
Read Message History |
Nachrichten lesen | ja |
Send Messages |
nicht benötigt | nein |
Manage Messages / Administrator |
nicht benötigt | nein |
Gateway-Intent Message Content |
nicht nötig (HTTP-API, kein Gateway) | nein |
Der Bot braucht keine Schreibrechte und keine Administratorrolle. Es werden
ausschließlich GET-Aufrufe abgesetzt (/channels/{id}/messages), mit
Backoff/Retry und der Auswertung von Retry-After bei HTTP 429.
Wichtige Einschränkung: Discord gibt einem Bot nur Nachrichten, die er selbst sehen darf – in der Regel erst ab dem Zeitpunkt, zu dem die Berechtigung erteilt wurde. Ein vollständiger historischer Backfill per Bot ist nicht garantiert. Für historische Daten ist der Dateiimport der vorgesehene Weg. Das gilt auch dann, wenn Discord die API-Zugriffe nicht ablehnt.
Embeddings und semantische Suche
Die Stichwortsuche funktioniert immer und benötigt keine KI. Für die semantische Suche wird ein konfigurierbarer Provider benötigt.
Provider
| Provider | Einrichtung | Datenschutz |
|---|---|---|
openai |
OPENAI_API_KEY, EMBEDDING_MODEL |
Nachrichtentexte verlassen die eigene Infrastruktur |
ollama |
OLLAMA_BASE_URL, Modell vorher ollama pull |
Verarbeitung bleibt lokal |
mock |
keine | Nur Entwicklungs-/Testzwecke; deterministische Platzhaltervektoren, in Produktion abgelehnt |
OPENAI_BASE_URL erlaubt kompatible Endpunkte (LiteLLM, vLLM, Azure-OpenAI über
Proxy) ohne Codeänderung.
Pipeline
- Abschnittbildung (
chunks.build): zusammenhängende Nachrichten desselben Channels/Threads werden zu Abschnitten gruppiert. Konfigurierbar überCHUNK_MAX_CHARS(Standard 1600),CHUNK_MAX_MESSAGES(40) undCHUNK_MAX_GAP_SECONDS(900). Eine zu lange Einzelnachricht wird nie mitten drin abgeschnitten. - Embedding-Erzeugung (
embeddings.backfill): Batches (Standard 64), Status je Abschnitt (pending,done,failed,stale), exponentielles Backoff bei 429/5xx, Tagesbudget. - Modellwechsel: Vektoren werden mit Modellname und Dimension gespeichert. Die semantische Suche filtert auf das aktuell konfigurierte Modell – alte Vektoren liefern kein irreführendes Ergebnis, sondern gar keins.
Embedding-Modell wechseln
Bei anderer Dimension ist eine Migration nötig (die Dimension ist Teil des Schemas):
# 1. Dimension in src/db/schema.ts anpassen (EMBEDDING_DIMENSIONS)
# 2. Section nach Anleitung umbenennen bzw. Migration schreiben:
ALTER TABLE message_chunks ALTER COLUMN embedding TYPE vector(768);
# 3. Alte Vektoren verwerfen und neu berechnen:
UPDATE message_chunks SET embedding = NULL, embedding_status = 'pending',
embedding_model = NULL, embedded_content_hash = NULL;
Anschließend npm run worker (der Backfill läuft beim Start erneut).
Hintergrundverarbeitung
Aufgaben laufen über eine pg-boss-Queue in derselben PostgreSQL-Datenbank. Kein Redis erforderlich.
npm run worker # dauerhaft laufen lassen
npm run worker:watch # mit Watch-Modus
| Aufgabe | Auslöser |
|---|---|
import.run |
Upload im Admin-Bereich |
chunks.build |
nach Import, manuell im Admin-Bereich |
embeddings.backfill |
nach Import, manuell, Zeitplan beim Worker-Start |
topics.extract |
beim Öffnen einer Themen-/Personenseite |
discord.sync |
Zeitplan (DISCORD_SYNC_INTERVAL_MINUTES) und manuell |
maintenance.cleanup |
täglich 04:17 |
Zustand, Fortschritt und Fehler landen in job_runs und sind unter
Import & Verarbeitung sichtbar. Aufgaben sind wiederholbar: Fehler werden mit
Backoff erneut eingereiht (Standard 3 Versuche).
Funktionsumfang der Oberfläche
| Seite | Inhalt |
|---|---|
| Login | OIDC-Anmeldung; bei fehlender Konfiguration eine Anleitung statt eines Bypass |
| Dashboard | Gesamtzahl Nachrichten, eindeutige Personen, Nachrichten pro Tag/Woche/Monat, Aktivität nach Wochentag und Tageszeit, aktivste Channels, Nachrichten pro Person (paginiert), Import- und Embedding-Fortschritt |
| Suche | Stichwort / Hybrid / semantisch, Filter für Zeitraum, Channel, Autor, Seitenzahl; Treffergrund, Kontextnachrichten, Sprung zum Original |
| Nachrichtendetail | Originaltext, Diskussionsabschnitt, Antwortbezug, Zeitliches Umfeld, Import-Metadaten |
| Personen | Häufige Themen je Person mit Belegen, Datenbasis und Zeitraum, Ausschluss-Funktion für Admins |
| Themen | Häufige Begriffe je Channel und serverweit, Belege, Verlauf |
| Import & Verarbeitung | Upload mit Vorschau, Fortschritt, Fehlerprotokoll, Job-Übersicht, KI-Verbrauch |
| Administration | Allowlist, Nutzerrollen, Analyse-Einstellungen, Einzellöschung, Komplettlöschung, Konfigübersicht |
Suche
- Hybrid kombiniert Volltextsuche (
websearch_to_tsquery, deutsche Postgres-Textkonfiguration) und Vektorsuche (pgvector, Cosinus) über Reciprocal Rank Fusion. RRF wird verwendet, weil BM25-Ränge und Vektorähnlichkeiten nicht vergleichbar skaliert sind; der Wert ist eine Rangfolge, keine Wahrscheinlichkeit. - Jeder Treffer zeigt worauf er zurückgeht: Stichworttreffer (mit gefundenen Begriffen), semantische Ähnlichkeit und den RRF-Wert.
- Ähnliche Vektoren werden nie als gesicherte Tatsachen dargestellt.
- Filterzustände liegen in den URL-Query-Parametern und sind damit teilbar.
Frage-Antwort-Funktion (optional)
Das Modell erhält ausschließlich die für die Frage relevanten Treffer (Standard: bis zu 12 Nachrichten) – nie den ganzen Channel. Regeln:
- Jede Aussage muss mit
[Q1],[Q2], … belegt werden. - Ohne Beleg wird exakt „Die verfügbaren Quellen erlauben keine belastbare Antwort.“ ausgegeben.
- Die Quellen erscheinen unter jeder Antwort mit Autor, Kanal, Zeitpunkt und klickbarem Discord-Permalink; zitierte und nicht zitierte sind gekennzeichnet.
- Antworten sind dauerhaft als KI-Auswertung gekennzeichnet.
Architektur und Datenmodell
Stack (bewusste Entscheidungen)
| Entscheidung | Begründung |
|---|---|
| Next.js App Router | Alle Seiten serverseitig gerendert → keine Inhalte im Browser-Bundle, keine Client-seitigen Datenabfragen. |
| PostgreSQL + pgvector | Volltextsuche, JSONB, Transaktionen und Vektorsuche in einem System. Kein zweiter Dienst. |
| Drizzle ORM | Typisierte Queries ohne Runtime-Magie; Migrationen sind nachvollziehbares SQL. |
| pg-boss statt Redis | Eine Queue weniger im Betrieb. Jobs sind transaktional abgesichert und überstehen Neustarts. |
| Eigener JSON-Stream-Scanner | Ein 300-MB-DiscordChatExporter-Export ist ein einzelnes JSON-Objekt. JSON.parse würde GB RAM benötigen. |
| RRF statt Score-Mixing | Vergleicht nur Ränge statt inkompatibler Scores; nachvollziehbar und stabil. |
| Statistische Themen statt KI-Themen | Themen lassen sich exakt nachrechnen und funktionieren ohne KI. |
Übersicht der Komponenten
src/
ai/ Provider-Abstraktion (openai | ollama | mock), Retry/Backoff
analysis/ Themenextraktion, quellenbasierte QA, Datenschutz-Leitplanken
app/ Next.js App Router: Seiten, Server Actions, Auth-Routen
auth/ OIDC (PKCE), Sessions, Allowlist, Zugriffs-Guards
db/ Drizzle-Schema, Client, Migrationen
discord/ Optionale Bot-Synchronisierung
import/ Adapter (JSON/JSONL/CSV), Streaming-Scanner, Upsert-Pipeline
jobs/ pg-boss-Queue, Handler, Worker
search/ Filter, Volltext, Vektor, RRF, Chunking, Embedding-Backfill
stats/ SQL-Aggregationen für das Dashboard
Tabellen und Beziehungen
guilds 1───* channels 1───* messages 1───1 authors
│ │
│ ├──* chunk_messages *──1 message_chunks
│ │ (embedding, embedding_status)
└── import_runs ──* import_errors
users 1───* sessions auth_allowlist (E-Mail/Domain)
users 1───* llm_answers
topic_scopes (scope_type, scope_id) 1───* topic_terms
1───* topic_summaries
person_analysis_exclusions ──1 authors
job_runs · settings · rate_limit_counters · ai_usage_daily · discord_sync_state
| Tabelle | Zweck | Löschregel |
|---|---|---|
users |
angemeldete Identitäten (nur E-Mail, Name, Rollen) | manuell |
sessions |
serverseitige Sitzungen (nur Token-Hash) | CASCADE bei Nutzerlöschung, Ablauf wird automatisch entfernt |
auth_allowlist |
statische Freischaltungen | manuell |
guilds / channels |
Server und Channels | CASCADE zu Nachrichten |
authors |
Autoren:innen mit Nachrichtenzähler | CASCADE zu Ausschlüssen |
messages |
Nachrichten inkl. search_vector (generiert) |
import_run_id → SET NULL |
message_chunks |
Suchabschnitte mit Embedding (pgvector, HNSW) | CASCADE über chunk_messages |
chunk_messages |
Abschnitt → Originalnachricht (Reihenfolge erhalten) | CASCADE |
import_runs / import_errors |
Fortschritt und Fehler je Lauf | CASCADE zu Fehlern |
topic_terms / topic_scopes / topic_summaries |
Themen und deren Datenbasis | manuell |
llm_answers |
protokollierte KI-Antworten | manuell |
job_runs |
Fortschritt der Hintergrundaufgaben | CASCADE bei Importlöschung |
ai_usage_daily |
Token-Verbrauch je Tag und Provider | manuell |
Nachrichtenmodell
Gespeichert werden: Discord-ID, Guild-ID/-Name, Channel-ID/-Name, Autor-ID und Anzeigename, Zeitstempel, Text, Thread-/Parent-Bezug, Antwortbezug, Edit-/ Delete-Status, Permalink, Importquelle und Importzeitpunkt.
Nicht gespeichert werden: Anhänge, Bilder, Sticker, Embeds, Nur Zahlen
angefügt (has_attachment, attachment_count) – das reduziert
personenbezogene Daten erheblich.
Skalierung auf 500.000 Nachrichten
Gemessene Größen
| Größenordnung | Umsetzung |
|---|---|
| Import | Streaming-Scanner + Massen-Upsert über unnest in Batches von 500 → konstanter Speicherbedarf, kein Volumen im RAM |
| Indexierung | messages_search_vector_idx (GIN, generierte Spalte), messages_channel_timestamp_idx, messages_author_timestamp_idx, messages_discord_id_uidx |
| Vektorsuche | HNSW-Index message_chunks_embedding_hnsw_idx (vector_cosine_ops, m=16, ef_construction=64), nur für Zeilen mit Embedding |
| Abschnitte | Aufbau über Server-Cursor in Seiten von 2000 Nachrichten |
| Themen | Cursor-Streaming; im RAM nur die Wortliste |
| Statistiken | Alle Kennzahlen als SQL-Aggregation, Zeitraumfilter über Indizes |
| Seiten | ausschließlich serverseitige Pagination, keine unvirtualisierten Langlisten |
Empfohlene Postgres-Einstellungen
docker-compose.yml setzt bereits shared_buffers, effective_cache_size,
work_mem und maintenance_work_mem. Für eine dedizierte Maschine:
shared_buffers = 2GB # ~25 % des RAM
effective_cache_size = 6GB
work_mem = 32MB
maintenance_work_mem = 1GB # beschleunigt den HNSW-Aufbau deutlich
max_wal_size = 4GB
random_page_cost = 1.1 # SSD
effective_io_concurrency = 200
timezone = 'Europe/Berlin'
HNSW-Index und Wartung
Der Aufbau eines HNSW-Index über 100.000+ Vektoren dauert einige Minuten und
braucht maintenance_work_mem. Falls pgvector meldet
memory needed for index build …, maintenance_work_mem erhöhen.
Nach einem großen Import empfiehlt sich:
VACUUM ANALYZE messages;
VACUUM ANALYZE message_chunks;
REINDEX INDEX CONCURRENTLY messages_search_vector_idx; -- nur wenn nötig
Nachgewiesene Testabdeckung
tests/db/ enthält Integrationstests gegen eine echte Datenbank, unter anderem
einen Massenimport von 20.000 Nachrichten mit Messung der Importdauer und
einer Plausibilitätsgrenze (< 60 s), damit eine Regression (z. B. Batch-Größe
- sofort auffällt. Diese Tests laufen mit
npm run test:dbund sind ohne Datenbank automatisch übersprungen – siehe Tests.
Reproduzieren
docker compose up -d db
npm run db:migrate
npm run sample:generate -- --count 500000 --out data/samples/big.json
npm run sample:import -- data/samples/big.json
npm run worker # Abschnitte + Embeddings
Sicherheit
| Maßnahme | Umsetzung |
|---|---|
| Zugriff | Serverseitiger Guard auf jeder Seite, Server Action und API-Route (src/auth/guard.ts) |
| Bypass-Sperre | AUTH_BYPASS_ENABLED wirkt ausschließlich außerhalb von NODE_ENV=production (testgesichert) |
| Datenbankausfall | Führt zu /nicht-verfuegbar mit Checkliste statt zu einem 500er; der Zugriff bleibt gesperrt |
| Sessions | HTTP-only, SameSite=Lax, Secure in Produktion; DB speichert nur sha256 |
| CSRF | Origin-/Sec-Fetch-Site-Prüfung für Route Handler; Next.js prüft Server Actions selbst |
| SQL-Injection | Ausschließlich gebundene Parameter; Filterwerte werden zusätzlich per Whitelist validiert |
| XSS | Chattexte werden nie per dangerouslySetInnerHTML gerendert; Highlights als Segmente |
| Uploads | Endungs-Whitelist, Größengrenze während des Schreibens, nicht-öffentliches Verzeichnis, Path-Traverse-Schutz, Dateiname nur Basisname |
| Missbrauch | Rate-Limit (DB-gestützt, wirkt über mehrere Instanzen) für Suche, KI und Login-nahe Aufgaben |
| Header | Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Externe Links | rel="noopener noreferrer nofollow" |
| Löschfunktionen | Einzelne Nachricht und kompletter Importlauf, jeweils mit Bestätigungstext und Admin-Recht |
| Logs | Niemals vollständige Nachrichtentexte; nur IDs, Zähler, Fehlermeldungen |
Zusätzlich empfohlen, nicht von der Anwendung übernommen:
- Reverse Proxy mit TLS, Ratenbegrenzung und
max_request_body_size TRUST_PROXY_HEADERS=truenur, wenn ein vertrauenswürdiger Proxy vorangeschaltet ist- Zugriff auf die Anwendung zusätzlich per VPN oder IP-Beschränkung
Datenschutz und Rechtsfragen
Diese Anwendung garantiert keine Rechtskonformität. Die folgenden Hinweise sind keine Rechtsberatung, sondern eine technische Einordnung.
Welche Daten verarbeitet werden
Nachrichtentexte, Anzeigenamen, Discord-IDs, Zeitstempel, Antwortbezüge. Nicht gespeichert: Anhänge, Bilder, Sticker, Embed-Inhalte. IP-Adressen werden nur als Hash in Sessions gespeichert.
Zu bewerten durch den Betreiber
- Rechtsgrundlage für die Verarbeitung (z. B. Art. 6 Abs. 1 lit. f DSGVO bei berechtigtem Interesse, oder Einwilligung)
- Informationspflichten gegenüber den betroffenen Personen
- Löschfristen und Aufbewahrung
- Auftragsverarbeitungsverträge, wenn externe KI-Anbieter oder externe Hosting genutzt werden (Art. 28 DSGVO)
- Zugriffsrechte – einzelne Nachrichten können über Administration gelöscht werden; für die Bearbeitung von Auskunftsersuchen ist das der vorgesehene Weg
Personenanalysen: bewusste Grenzen
Die Anwendung erstellt keine Persönlichkeitsprofile und keine Ranglisten nach Wert oder Einfluss. Technisch umgesetzt:
- Formulierung als Frequenzangabe („in 25 von 200 Nachrichten“), nie als Eigenschaft einer Person
- Verboten und gefiltert: psychologische Diagnosen, Aussagen zu Religion,
Gesundheit, Politik, Sexualität, Herkunft, Vertrauenswürdigkeit (
src/analysis/privacy.ts) - Fragen nach solchen Eigenschaften werden abgelehnt, ohne das Modell zu befragen
- Jede Aussage verweist auf konkrete Originalnachrichten
- Datenbasis und Zeitraum werden immer angezeigt; bei zu wenig Material wird gar keine Aussage getroffen
- Personen können über Administration bzw. die Personenübersicht ausgeschlossen
werden;
PERSON_ANALYSIS_ENABLED=falseschaltet die Analyse global ab
Externe KI-Anbieter
Wird ein externer Embedding- oder Chat-Provider verwendet, werden die jeweils benötigten Nachrichtentexte an diesen übertragen:
- Embeddings: vollständige Abschnittstexte (bis zu ~1600 Zeichen)
- Chat/Antwort: die 10–12 zur Frage passenden Nachrichtenauszüge
Die Oberfläche weist darauf hin, wenn ein externer Provider aktiv ist. Für eine
Verarbeitung ohne Weitergabe EMBEDDING_PROVIDER=ollama und
CHAT_PROVIDER=ollama setzen und die Modelle lokal bereitstellen.
Backups
Die Anwendung führt keine Backups automatisch aus. Empfohlenes Vorgehen:
# täglich per cronjob
docker compose exec -T db pg_dump -U quackanal -Fc quackanal > "backup-$(date +%F).dump"
# Wiederherstellung (überschreibt den aktuellen Stand!)
docker compose exec -T db pg_restore -U quackanal -d quackanal --clean --if-exists < backup-2025-01-01.dump
# Importdateien liegen getrennt unter data/uploads und sollten mitgesichert werden
tar czf uploads-$(date +%F).tar.gz data/uploads
Aufbewahrung und Verschlüsselung der Backups sind zu regeln; sie enthalten denselben Datenbestand wie die Datenbank.
Tests, Linting, Typechecking
npm run verify # lint + typecheck + Unit- und Integrationstests
| Befehl | Inhalt |
|---|---|
npm run lint |
ESLint 9 (Flat Config, eslint-config-next) |
npm run typecheck |
tsc --noEmit, strict + noUncheckedIndexedAccess |
npm test |
Vitest: ohne Datenbank lauffähig |
npm run test:db |
zusätzlich Integrationstests gegen PostgreSQL |
Abgedeckt
| Bereich | Datei |
|---|---|
| Streaming-JSON-Scanner (Chunk-Grenzen, Escapes, Fehler) | tests/import/json-stream.test.ts |
| CSV-Parser (Quotes, Mehrzeiler, Chunk-Grenzen) | tests/import/csv.test.ts |
| Normalisierung, synthetische IDs, Zeitstempel | tests/import/normalize.test.ts |
| Format-Erkennung und alle drei Adapter | tests/import/adapters.test.ts |
| Deduplizierung und Edit-Erkennung | tests/db/import.test.ts |
| Massenimport (20.000 Nachrichten) | tests/db/import.test.ts |
| Zugriffsschutz (Allowlist, Admin, gesperrte Konten, Session-Token) | tests/auth/access.test.ts |
| Eingabevalidierung der Suche (Injektion, Grenzwerte) | tests/search/query.test.ts |
| Suche, Filter, Kontext, Fusion, Modellwechsel | tests/db/search.test.ts |
| RRF, SQL-Parameter, Filterbildung | tests/search/rrf-sql.test.ts |
| Chunking | tests/search/chunking.test.ts |
| KI-Provider mit gemockten HTTP-Antworten | tests/ai/providers.test.ts |
| Quellenzuordnung von KI-Antworten | tests/security/search-highlight.test.ts |
| Datenschutz-Leitplanken | tests/analysis/privacy.test.ts |
| Statistiken und Themen | tests/db/stats-topics.test.ts |
| CSRF/Origin, XSS-sicheres Rendering | tests/security/…, tests/ui/rendering.test.tsx |
| Bypass-Sperre in Produktion, keine DB-Anlage bei inaktivem Bypass | tests/auth/bypass.test.ts, tests/auth/bypass-db-isolation.test.ts |
Integrationstests
docker compose up -d db
npm run db:migrate
npm run test:db # RUN_DB_TESTS=1
Ohne RUN_DB_TESTS=1 werden sie übersprungen (describe.skip), damit
npm test auch ohne Docker funktioniert.
Ende-zu-Ende
npx playwright install chromium
npm run sample:generate -- --count 400
npm run sample:import -- data/samples/discord-export-sample.json
npm run worker &
npm run dev &
TEST_DATABASE_URL=postgresql://quackanal:quackanal@localhost:5432/quackanal npm run e2e
Die E2E-Tests legen eine Session direkt in der Testdatenbank an (kein OIDC nötig) und prüfen: Zugriffsschutz ohne Session, Dashboard, Suche inkl. Sprung zum Original, Datenschutz-Hinweise, Themen, Status und Administration.
Bekannte Einschränkungen
- Historischer Backfill per Bot ist nicht garantiert. Discord zeigt einem Bot nur Nachrichten ab dem Zeitpunkt der Berechtigung. Historische Daten gehören über Dateiimport.
- Kein Attachmentspeicher. Anhänge werden nicht importiert; die Aussage „hat 3 Anhänge“ bleibt bestehen, der Inhalt fehlt.
- Embeds und Sticker fehlen. Nachrichten, deren Inhalt nur in einem Embed oder Sticker steckt, gelten als „ohne Text“ und werden standardmäßig übersprungen.
- DiscordChatExporter-CSV ohne Zeitzone wird als UTC interpretiert. Für deutsche Sommerzeit verschieben sich solche Nachrichten dadurch um bis zu zwei Stunden.
- Embeddings sind asynchron. Bis der Backfill durch ist, arbeitet die Suche nur mit Stichworten. Das ist beabsichtigt und wird in der Oberfläche angezeigt.
- Themenlisten sind Momentaufnahmen. Sie werden beim Öffnen der Seite erzeugt, nicht laufend nachgeführt.
- Keine Mehrmandantenfähigkeit. Ein
AUTH_ALLOW_ALL=truegibt allen angemeldeten Personen Zugriff auf denselben Bestand. - RRF-Werte sind keine Wahrscheinlichkeiten. Sie sind nur zur Reihenfolge innerhalb einer Suche geeignet.
- Keine Volltextsuche in Anhängen, reactionen oder Embeds, weil diese Daten nicht gespeichert werden.
docker composeist für die Entwicklung gedacht, nicht für den Produktivbetrieb. Für Produktion: TLS, eigene Secrets, getrennte Datenbankinstanz, Monitoring.- E2E-Tests wurden in dieser Umgebung nicht ausgeführt, weil kein Docker verfügbar war. Sie sind enthalten und lauffähig (siehe oben).
Weiterführend
- pgvector – HNSW-Index, Wartung
- pg-boss – Hintergrundaufgaben in Postgres
- openid-client – OIDC/PKCE
- DiscordChatExporter – Dateiexporte
- Pocket ID – selbst gehosteter Identitätsanbieter
- Drizzle ORM – Schema und Migrationen