FastAPI-UI für KI-Tagging mehrerer Paperless-NGX-Instanzen via lokalem Ollama und n8n. OIDC, verschlüsselte Token, gehärteter Container.
  • Python 54.2%
  • HTML 36.6%
  • CSS 9.1%
  • Dockerfile 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Manuel Gläser 5eae5ab6bb cleanup-skript: retroaktive Tag-Bereinigung Kontoauszug/Depotauszug
Einmaliges Helper-Skript für Mass-Cleanup historischer Falsch-Tags
auf Kontoauszug-Docs. Nutzt _filter_llm_tags_by_inhaber_scope —
entfernt also nur Tags deren Name NICHT im Header (erste 500 chars)
oder Footer (letzte 500 chars) vorkommt. Tags wie 'Eingang' (in
PROTECTED_TAGS) bleiben immer.

Default Dry-Run, --apply schreibt nach Paperless.

Für simple Bulk-Cleanups (pauschal Tag X von allen Kontoauszügen
entfernen) ist Paperless-Bulk-Edit schneller. Dieses Skript ist für
selektive Fälle wo der Tag bei manchen Docs korrekt sein KÖNNTE
(z.B. gemeinsame Konten) und nur die false-positives raus sollen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:09:23 +02:00
app cleanup-skript: retroaktive Tag-Bereinigung Kontoauszug/Depotauszug 2026-05-04 22:09:23 +02:00
.env.example sec: gate doc/prompt/llm-trace behind DEBUG, hide token, dedupe webhook 2026-04-28 19:42:41 +02:00
.gitignore Add README + AI-Tool-Verzeichnisse in .gitignore 2026-04-26 15:22:45 +02:00
docker-compose.yml fix(trace): details-button shows nothing after toggling errors-only filter 2026-04-28 19:20:42 +02:00
Dockerfile S7: remove n8n integration (compose, routes, healthcheck, templates) 2026-04-28 18:41:47 +02:00
README.md bootstrap: OIDC-Setup-Wizard für Erst-Deployment ohne OIDC-Config 2026-05-02 14:48:31 +02:00

paper.ai

Zentrale Konfigurations- und Steuerungs-UI für KI-Tagging mehrerer Paperless-NGX-Instanzen — vollständig self-hosted, ohne Cloud-Abhängigkeit.

Was es macht

  • Multi-Tenant: Mehrere User (OIDC), jede Paperless-Instanz hat einen Owner. Owner sieht/editiert nur seine — auch der Admin ist KEIN Daten-Superuser. Self-Service-Anlage über Wizard oder „+ Neue Instanz".
  • Multi-Instance pro User: Beliebig viele Paperless-Server pro Owner. Jede Instanz hat eigenes Modell, Prompt, Custom-Field-Mapping, Auto- oder Review-Mode.
  • Native KI-Pipeline: Paperless-Webhook → Worker im Container → Ollama (lokal) → Fuzzy-Match gegen Whitelists → Patch zurück nach Paperless. Kein n8n mehr, alles im selben Python-Prozess.
  • Modell-Verwaltung: Ollama-Library live von ollama.com gescraped (24h Cache), Pull/Delete + Live-Progress mit Speed/ETA direkt in der UI.
  • Per-Step-Trace: Jede Pipeline-Stufe (15 Steps pro Doc) mit komplettem Input/Output in der DB persistiert, im Job-Detail-UI durchsuchbar — ersetzt das n8n-Per-Node-Debugging dauerhaft. Token werden vor dem Speichern redaktiert.
  • Reprocess-UI: Bestehende Dokumente filtern, durchsuchen, einzeln oder in Bulk nochmal durch die KI schicken — mit Live-Status-Polling.
  • Audit-Log: Alle Konfig-Änderungen und Reprocess-Aktionen mit Vorher/Nachher-Diff, automatische Rotation. Admin sieht alles, User nur eigene Einträge + Einträge zu eigenen Instanzen.

Sicherheit

  • OIDC direkt im Container (Authentik / Keycloak / o.ä., RP-initiated Logout)
  • Optionaler Group-Membership-Check (OIDC_REQUIRED_GROUP)
  • Optionaler Admin-Group-Override (OIDC_ADMIN_GROUP)
  • Per-User-Owner-Filter auf allen Instanz-Routes — Admin ist kein Daten-Superuser
  • Paperless-API-Tokens Fernet-verschlüsselt in SQLite at-rest
  • Bearer-Token-Schutz für /api/*-Endpoints (Docker File-Secrets)
  • CSRF via Origin/Referer-Header-Check
  • SSRF-Allowlist für Paperless-URLs (blockt Loopback / link-local / IMDS)
  • HTTPX-TLS-Verify gegen System-CA-Bundle (für interne CAs)
  • Container: non-root UID 1000, cap_drop: ALL, read_only: true, no-new-privileges

Stack

  • FastAPI + Starlette + Jinja2 (SSR, kein JS-Framework)
  • SQLite mit File-basiertem Fernet-Encryption-Key
  • Authlib (OIDC), httpx (HTTP-Client mit System-CA)
  • Docker Compose, File-Secrets, Bind-Mounts

Architektur

Paperless-NGX  ── webhook ──▶  /api/webhook/paperless  ──▶  Worker-Queue
                                                                │
                                                                ▼
                                                       processor.process_document
                                                                ├─▶  Paperless API (GET/PATCH/POST)
                                                                ├─▶  Ollama /api/generate
                                                                └─▶  processing_trace + processing_log

Drei Module:

  • app/main.py — FastAPI-Routes, DB-Schema, OIDC, Worker-Loop, UI-Templates
  • app/paperless_client.py — alle HTTP-Calls gegen Paperless-NGX
  • app/processor.py — reine Pipeline (build_prompt, fuzzy_match, parse, call_ollama, …) + DBTracer für Per-Step-I/O-Persistierung

Voraussetzungen

  • Ollama (lokal oder im selben Docker-Netz, via OLLAMA_URL)
  • Reverse-Proxy davor (NPMPlus / Caddy / Traefik) für TLS-Termination
  • OIDC-Provider (Authentik, Keycloak, …)
  • Eine oder mehrere Paperless-NGX-Instanzen mit API-Token + Workflow-Aktion „Webhook" für jedes Doc-added-Event

Setup

git clone <repo-url> paper-ai
cd paper-ai
cp .env.example .env
$EDITOR .env

mkdir -p secrets data
# Secrets generieren (Beispiele)
openssl rand -hex 32 > secrets/config_api_token
openssl rand -hex 32 > secrets/session_secret
openssl rand -hex 32 > secrets/paperless_webhook_token
echo "<oidc-client-secret-aus-authentik>" > secrets/oidc_client_secret
chmod 400 secrets/*

docker compose up -d

Migration von älteren Versionen mit n8n: Bestehende Setups haben das Datei secrets/n8n_webhook_token. Den Inhalt nach paperless_webhook_token kopieren und in docker-compose.yml den secrets:-Eintrag entsprechend umbenennen. Bis zum Umbenennen liest main.py den alten Datei-Namen als Fallback weiter.

Die Container-User-UID ist 1000. Wenn deine Bind-Mount-Owner andere UIDs haben, entweder den user: in docker-compose.yml anpassen oder die Mounts chownen (docker run --rm -v ./data:/data alpine chown -R 1000:1000 /data).

OIDC einrichten

OIDC kann auf zwei Arten konfiguriert werden — entweder deklarativ über ENV + File-Secret (empfohlen für Production / IaC) oder interaktiv über den eingebauten Bootstrap-Wizard (empfohlen für schnelle Erst-Deployments auf neuen Hosts).

Variante A — Bootstrap-Wizard (interaktiv)

Wenn beim Container-Start keine OIDC-Config gefunden wird (weder ENV noch File-Secret) und ALLOW_INSECURE_UI nicht gesetzt ist, läuft die App im Bootstrap-Mode. Alle Routes außer /setup/oidc, /static/ und /api/healthz antworten mit 503 / Redirect auf den Wizard.

  1. docker compose up -d ohne OIDC-ENV starten.
  2. Im Browser auf https://<dein-host>/setup/oidc gehen.
  3. Discovery-URL, Client-ID, Client-Secret, App-Base-URL eintragen. Optional: Required-/Admin-Group, Logout-URL.
  4. Speichern → die Daten landen in data/oidc.env (chmod 600).
  5. Container manuell neu starten: docker compose restart paperless-ai-config.
  6. Nach dem Restart läuft normaler OIDC-Flow, Bootstrap-Wizard ist nicht mehr erreichbar (BOOTSTRAP_MODE=False).

data/oidc.env ist eine KEY=VALUE-Datei und kann auch manuell editiert werden. ENV-gesetzte Werte gewinnen weiterhin (deklarativ schlägt interaktiv) — OIDC_DISCOVERY_URL aus .env überschreibt jenes aus oidc.env.

Variante B — ENV deklarativ

Hot-Reload gibt es nicht (Authlib initialisiert beim Container-Start).

Pflicht-ENV in .env:

OIDC_DISCOVERY_URL=https://auth.example.com/application/o/paper-ai/.well-known/openid-configuration
OIDC_CLIENT_ID=paper-ai
SESSION_SECRET=<openssl rand -hex 32>
APP_BASE_URL=https://paperai.example.com   # für korrekte Redirect-URI

Pflicht-Secret:

echo "<client-secret-aus-deinem-IdP>" > secrets/oidc_client_secret
chmod 400 secrets/oidc_client_secret

Optional:

OIDC_REQUIRED_GROUP=paper-ai-users        # nur User in dieser Group dürfen rein
OIDC_ADMIN_GROUP=paper-ai-admins          # User in dieser Group werden Admins
OIDC_LOGOUT_URL=https://auth.example.com/if/flow/default-invalidation-flow/

Authentik-Beispiel

Im Authentik-Admin: Applications → Providers → Create → OAuth2/OpenID:

Feld Wert
Name paper-ai
Client type Confidential
Client ID paper-ai
Client Secret (auto-generiert — in secrets/oidc_client_secret schreiben)
Redirect URIs https://paperai.example.com/auth/callback
Scopes openid email profile
Subject mode Hashed user ID (oder „User UUID" — stabil!)
Include claims in id_token AN (sonst kommen groups/preferred_username nicht im ID-Token)

Application erstellen und mit dem Provider verbinden. Discovery-URL findest du dann unter dem Provider als „OpenID Configuration URL".

Erster Login wird automatisch Admin — danach kannst du in /admin/users weitere User auf Admin promoten oder per OIDC_ADMIN_GROUP über Group- Membership steuern.

Multi-User / Admin

Beim allerersten Container-Start ist die users-Tabelle leer. Der erste User, der sich per OIDC einloggt, wird automatisch zum Admin promoted. Alle weiteren User starten mit Role user.

Rollen-Modell:

  • User: sieht und editiert nur seine eigenen Paperless-Instanzen (Index, Jobs, Reprocess, Audit-Log gefiltert auf eigene actor-Einträge
    • eigene Instanzen).
  • Admin: zusätzliche Rechte bei
    • Audit-Log: sieht alle Einträge aller User (zeigt nur UI-Änderungen, keine Doc-Inhalte).
    • Globale Configs: Default-Prompt, Title-Rules scope='global', Doctype-Prompts instance='*', Modelle (Ollama-Pool), Notifications, App-Settings — POST-Endpoints sind Admin-only.
    • User-Verwaltung: /admin/users listet alle User mit Role-Toggle. Self-Demote-Schutz: letzter Admin kann sich nicht selbst zum User machen.
    • Backup/Restore + Migration-Banner für Bestands-Instanzen ohne Owner.

Wichtig: Auch ein Admin sieht nicht die Paperless-Instanzen anderer User auf Index/Jobs/Reprocess. Wer Daten anderer User braucht, muss als Owner eingetragen sein (über instance_edit.html Owner-Transfer).

OIDC_ADMIN_GROUP (ENV, optional): User die in dieser Authentik-Group sind, werden auch beim Re-Login als Admin promoted. Es gibt kein Auto-Demote — wer einmal Admin war, bleibt es bis zum manuellen Toggle über /admin/users. Recovery-Schutz: falls die Group-Konfiguration in Authentik kaputt geht, sperrt sich der Admin nicht selbst aus.

First-Run-Wizard

User mit 0 eigenen Instanzen werden auf /setup geleitet — 4-Step-Flow:

  1. Welcome
  2. Erste Paperless-Instanz (Slug, Display-Name, URL, Token, Modus)
  3. Modell-Wahl (Dropdown der installierten Ollama-Modelle, Hinweis-Link auf /models zum Pullen neuer)
  4. Webhook-Anleitung mit URL + Bearer-Header

Admin mit ≥1 eigener Instanz sieht zusätzlich einen „Überspringen"-Button, der ein Skip-Flag in der Session setzt.

Bestands-Instanzen migrieren

Bei Update einer bestehenden Single-Tenant-DB auf Multi-Tenant kriegen alle Instanzen owner_user_id=0 (orphaned). Zwei Wege zur Zuweisung:

Option A — UI-Banner (empfohlen)

Sobald ein Admin sich einloggt, erscheint auf / ein Migration-Banner mit Tabelle. Pro Instanz ein Owner-Dropdown. „Zuweisen" → Audit-Eintrag pro Transfer, Banner verschwindet.

Wenn ein gewünschter Owner sich noch nie eingeloggt hat, kannst du einen Platzhalter-User anlegen:

docker exec paperless-ai-config python -c "
import sqlite3
c = sqlite3.connect('/data/config.db')
c.execute(\"INSERT INTO users (sub, username, email, role) VALUES ('placeholder-fabi', 'Fabi', NULL, 'user')\")
c.commit()
"

Sub muss mit placeholder- beginnen — dann übernimmt upsert_user_from_session beim ersten echten OIDC-Login automatisch den Eintrag (Username-Match case-insensitive, sub wird auf den echten Authentik-Wert überschrieben, Owner-FKs bleiben intakt).

Option B — ENV-Bootstrap

Pro Instanz in .env setzen (User muss vorher mind. 1× eingeloggt sein, sonst greift das Mapping erst beim nächsten Container-Start):

INSTANCE_OWNER_<slug>=<username>

Beispiel:

INSTANCE_OWNER_manu=Manuel
INSTANCE_OWNER_fabi=Fabi
INSTANCE_OWNER_hbloos=Fabi

init_db() mapped beim Container-Start orphaned Instanzen automatisch. Username case-sensitive (entspricht users.username aus dem ID-Token).

Edge-Case: Authentik sub ändert sich

Wenn der Authentik-Provider neu konfiguriert wird und sub-Werte sich ändern, würde upsert_user_from_session einen neuen User-Row anlegen statt den bestehenden zu finden — bestehende Owner-Beziehungen bleiben am alten Eintrag hängen. Manueller Fix per SQL:

docker exec paperless-ai-config python -c "
import sqlite3
c = sqlite3.connect('/data/config.db')
c.execute('UPDATE users SET sub=? WHERE username=?', ('<neuer-sub>', '<Username>'))
c.execute('DELETE FROM users WHERE id=<dup-id>')
c.commit()
"

Re-Link-UI im /admin/users-Bereich ist Phase-2-Feature.

Paperless-NGX einrichten

In Paperless eine Workflow-Aktion vom Typ Webhook anlegen:

Feld Wert
Aktionstyp Webhook
Webhook-URL https://<dein-host>/api/webhook/paperless
Parameter für Webhook-Inhalt verwenden AN
Webhook-Payload als JSON senden AN
Webhook-Parameter — Key doc_url
Webhook-Parameter — Value {{ doc_url }} (Paperless-Template-Variable)
Webhook-Kopfzeilen — Key Authorization
Webhook-Kopfzeilen — Value Bearer <Inhalt aus secrets/paperless_webhook_token>
Dokument einbeziehen AUS (PDF wird nicht gebraucht)

Trigger: „Dokument hinzugefügt" (typischer Hauptanwendungsfall) und optional „Dokument aktualisiert" mit Tag-Filter (z.B. ein reprocess-Tag).

Die UI-Seite /webhook-info zeigt diese Tabelle live mit der konkreten URL und einem Copy-Button für den Token.

Pipeline-Trace

Auf der Job-Detail-Seite (/jobs/<id>) zeigt der Block „Pipeline-Trace" pro verarbeitetem Dokument alle 15 Pipeline-Schritte mit Input/Output, Dauer und Status. Filter „nur Fehler-Steps" für schnelles Debugging.

Retention via app_settings-Tabelle (Defaults: ok-Steps 7 Tage, error-Steps 30 Tage, Hard-Limit 200 MB).

Datenschutz / DEBUG-Modus: Per Default landen keine vollständigen Doc-Inhalte / Prompts / LLM-Rohantworten in der Trace-DB — nur Längen, plus ein 500-Zeichen-Preview der LLM-Antwort. Für Prompt-Tuning und tiefes Debugging in der .env DEBUG=1 setzen und Container neu starten — dann werden fetch_document.content, build_prompt.prompt und ollama_call.raw_response voll persistiert. Achtung: Doc-Inhalte (Briefe, Rechnungen) können dann auch in config.db enden.

Legacy-API

Die Endpoints /api/instance/{name}/config und /api/resolve liefern den Paperless-Token im Klartext nur, wenn LEGACY_CONFIG_API=1 in der .env gesetzt ist (Default 0). Der native Worker liest den Token direkt aus der DB und braucht diesen API-Pfad nicht. Für externe Aufrufer (alte n8n-Setups, eigene Skripte) als Fallback verfügbar.

Boot-Robustheit (Host-Bind nach Reboot)

Das Compose-File bindet Port 8001 per Default an die statische Host-IP aus HOST_BIND_IP (z.B. 10.0.63.2). Wird die IP per DHCP vergeben (allow-hotplug ens18 / iface ens18 inet dhcp), startet Docker beim Reboot häufig schneller als der DHCP-Lease — Container exited mit failed to bind host port: cannot assign requested address und der Reverse-Proxy bekommt 502.

Zwei Maßnahmen auf dem Host fixen das dauerhaft:

1. ip_nonlocal_bind aktivieren (Bind auf noch nicht existente IP erlaubt):

sudo tee /etc/sysctl.d/99-docker-bind.conf >/dev/null <<'EOF'
net.ipv4.ip_nonlocal_bind = 1
EOF
sudo sysctl --system

2. Wait-Service vor Docker (blockt bis Host-IP wirklich da ist):

sudo tee /etc/systemd/system/wait-for-host-ip.service >/dev/null <<'EOF'
[Unit]
Description=Wait for DHCP-assigned host IP on ens18
DefaultDependencies=no
After=network.target networking.service
Before=network-online.target docker.service
Wants=network.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'for i in $(seq 1 60); do ip -4 addr show dev ens18 | grep -q "inet 10.0.63.2" && exit 0; sleep 1; done; echo "wait-for-host-ip: Timeout, IP nicht erschienen" >&2; exit 0'
TimeoutStartSec=70

[Install]
WantedBy=multi-user.target
EOF

sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/wait-for-host-ip.conf >/dev/null <<'EOF'
[Unit]
After=wait-for-host-ip.service
Requires=wait-for-host-ip.service
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now wait-for-host-ip.service

Interface (ens18) und IP (10.0.63.2) im Wait-Service ggf. an die eigene Host-Konfig anpassen. Der Service wartet max. 60s und gibt dann auf (Best-Effort, kein Boot-Hang bei dauerhaftem Netz-Ausfall) — das ip_nonlocal_bind aus Schritt 1 fängt diesen Fall zusätzlich ab.

Status

Läuft produktiv im Homelab. Code ist auf praktische Robustheit ausgelegt, nicht auf Enterprise-Skala — keine Tests, kein CI, kein PyPI-Package. Pull Requests willkommen, aber kein Support-Versprechen.

Lizenz

MIT