- Python 54.2%
- HTML 36.6%
- CSS 9.1%
- Dockerfile 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| app | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
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-Templatesapp/paperless_client.py— alle HTTP-Calls gegen Paperless-NGXapp/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 nachpaperless_webhook_tokenkopieren und indocker-compose.ymldensecrets:-Eintrag entsprechend umbenennen. Bis zum Umbenennen liestmain.pyden 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.
docker compose up -dohne OIDC-ENV starten.- Im Browser auf
https://<dein-host>/setup/oidcgehen. - Discovery-URL, Client-ID, Client-Secret, App-Base-URL eintragen. Optional: Required-/Admin-Group, Logout-URL.
- Speichern → die Daten landen in
data/oidc.env(chmod 600). - Container manuell neu starten:
docker compose restart paperless-ai-config. - 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-Promptsinstance='*', Modelle (Ollama-Pool), Notifications, App-Settings — POST-Endpoints sind Admin-only. - User-Verwaltung:
/admin/userslistet 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:
- Welcome
- Erste Paperless-Instanz (Slug, Display-Name, URL, Token, Modus)
- Modell-Wahl (Dropdown der installierten Ollama-Modelle, Hinweis-Link
auf
/modelszum Pullen neuer) - 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