Zum Inhalt

E7 · Paperless v3 Neustart — Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Paperless-ngx 3.1 als Greenfield-Stack auf dem NUC produktiv nehmen: Zulauf über den HP 4302 (SMB) und Proton Mail (Bridge), native KI gegen llama-swap auf dem aiserver, Ordnerlogik über Speicherpfade, nächtlicher Export, Consume-Überwachung.

Architecture: Ein Compose-Stack paperless-ngx/ mit sechs Containern (App, Postgres, Redis, Gotenberg, Tika, Proton-Bridge) auf proxy_network + paperlessngx_internal. Dokumente auf /mnt/paperless (UNAS), Datenbank, Index und Consume auf der SSD. Secrets über den Infisical-Agent. Host-Bausteine (Samba-Freigabe, systemd-Timer, Textfile-Collector) liegen versioniert unter paperless-ngx/host-config/ und paperless-ngx/scripts/, Robin deployt sie nach Runbook.

Tech Stack: Docker Compose, paperless-ngx 3.1.3, PostgreSQL 18.6, Redis 8.10.1, Gotenberg 8.37.0, Tika 3.3.1.0, Proton Mail Bridge 3.26.0 (Image ghcr.io/enucatl/protonmail-bridge), Infisical Agent, Samba (nativ), systemd, node-exporter Textfile-Collector, Prometheus v3.13.1, Python 3.11+ mit uv (requests 2.34.2, PyYAML 6.0.3, pytest 9.1.1).

Spec: paperless-design.md (gleicher Ordner). Der Plan argumentiert aus der Spec; wer ausführt, liest beide.

Global Constraints

  • Kein NUC-Write durch Claude. Alle Schritte mit [ROBIN/NUC] führt Robin aus (ssh nuc-root für /etc, /opt und git pull). Claude liefert die Befehle, prüft Ergebnisse, die Robin zurückmeldet.
  • Kein Zugriff von außen. Nur der Traefik-Router paperless.home.robinwerner.net.
  • Keine Datenübernahme. Alte Verzeichnisse werden geleert (genau ein Testdokument).
  • /mnt/scanner bleibt unangetastet — weder verschieben noch kopieren.
  • UID/GID 977/988 für Paperless auf NUC, im Container (USERMAP_UID/GID) und auf dem UNAS-Export.
  • Image-Pins: paperless-ngx:3.1.3, postgres:18.6, redis:8.10.1, gotenberg:8.37.0, tika:3.3.1.0, protonmail-bridge:3.26.0.
  • Infisical: Projekt c4915514-a815-4c32-a497-d669aa51963f, Env dev, Pfad /paperless-ngx, gerendert nach /run/infisical/paperless-ngx.env.
  • KI-Endpunkt: http://10.10.10.7:9292/v1, Modelle brain-llamacpp (Vorschläge) und embed-llamacpp (Embeddings). PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=de-de (seit 22.09., vorher ungesetzt).
  • Repo-Konventionen: Doku deutsch mit Umlauten, Code/Commits/Variablen englisch, Conventional Commits, Feature-Branch
  • PR (nie direkt auf main), set -euo pipefail + shellcheck + shfmt -i 2 für Shell, ruff für Python, Funktionen ≤ 100 Zeilen.
  • Aus der Session-Memory: Bash-Aufrufe kurz halten, Dateien einzeln stagen, Commit-Text als Datei (git commit -F). borgmatic list --archive latest --path mnt/… (nicht --find). Nach git pull auf dem NUC Rechte prüfen: find /opt/rpi-docker-compose -type f ! -perm -o=r (erwartet: nur traefik/.env, ts3/.env).

Dateistruktur

Repo homeserver (Deploy-Pfad auf dem NUC: /opt/rpi-docker-compose/):

Datei Verantwortung Teil
paperless-ngx/docker-compose.yaml Stack-Definition (neu geschrieben) A
paperless-ngx/paperless.env gelöscht (getrackter Secret-Key) A
paperless-ngx/docs.md Service-Doku (neu geschrieben) A
infrastructure/infisical-agent/agent.yaml.example Template-Block /paperless-ngx A
CLAUDE.md Env-Datei-Liste, migrierte Stacks A
paperless-ngx/host-config/smb-paperless.conf Samba-Freigabe [paperless] (per include) B
paperless-ngx/host-config/paperless-export.{service,timer} nächtlicher document_exporter B
paperless-ngx/host-config/paperless-consume-collector.{service,timer} Consume-Stau-Metrik B
paperless-ngx/scripts/consume-stale-collector.sh schreibt paperless.prom B
monitoring-stack/configs/prometheus/rules/paperless-alerts.yml Alarmregeln B
monitoring-stack/configs/prometheus/rules/container-alerts.yml paperless_ngx in ContainerDown B
paperless-ngx/seed/schema.yaml Startschema (Tags, Typen, Pfade, Korrespondenten) B
paperless-ngx/seed/seed.py idempotenter Seed über die REST-API B
paperless-ngx/seed/tests/test_seed.py Tests für den Seed B

Repo homelab-documentation: docs/projects/homeserver/productivity/paperless-ngx.md (Teil F), docs/planung/2026-08/umsetzungs-log.md (je Session).

NUC (Robin): /etc/infisical/agent.yaml, /etc/samba/smb.conf, Systembenutzer paperless, /etc/systemd/system/paperless-*.{service,timer}, Datenverzeichnisse.

Zwei PRs im homeserver: PR-A (Teil A, Stack) und PR-B (Teil B, Host-Bausteine + Seed + Monitoring). Beide zweigen von origin/main ab und berühren disjunkte Dateien.


Teil A — Repo: Stack-Neubau (Branch feat/paperless-v3-e7)

Task A1: Arbeits-Branch

Files: keine

  • Step 1: Branch von origin/main
cd /home/robin/repos/homeserver
git fetch origin
git switch -c feat/paperless-v3-e7 origin/main
  • Step 2: Basis prüfen

Run: git log --oneline -1 && git status --short && ls paperless-ngx/ Expected: HEAD = aktueller origin/main, keine Änderungen, Verzeichnis enthält docker-compose.yaml docs.md paperless.env


Task A2: Infisical-Template-Block

Files: - Modify: infrastructure/infisical-agent/agent.yaml.example

Interfaces: - Produces: gerenderte Datei /run/infisical/paperless-ngx.env mit den Keys PAPERLESS_SECRET_KEY, POSTGRES_PASSWORD, PAPERLESS_DBPASS (Task A3 bindet sie per env_file ein, Task C1 legt die Werte an)

  • Step 1: Block einfügen

Vor der abschließenden Kommentarzeile # Pro neuem Stack einen weiteren Block anhängen, dann in der templates:-Liste ergänzen (gleiche Einrückung wie der speedtest-tracker-Block):

  - destination-path: /run/infisical/paperless-ngx.env
    template-content: |
      {{- with secret "c4915514-a815-4c32-a497-d669aa51963f" "dev" "/paperless-ngx" }}
      {{- range . }}
      {{ .Key }}={{ .Value }}
      {{- end }}
      {{- end }}
    config:
      polling-interval: 60s
  • Step 2: Lint

Run: yamllint -d relaxed infrastructure/infisical-agent/agent.yaml.example Expected: keine error-Zeilen (Warnungen zur Zeilenlänge sind vorbestehend)

  • Step 3: Commit
git add infrastructure/infisical-agent/agent.yaml.example
git commit -m "feat(infisical): render secrets for the paperless-ngx stack"

Task A3: docker-compose.yaml neu schreiben

Files: - Modify: paperless-ngx/docker-compose.yaml (vollständig ersetzen)

Interfaces: - Consumes: /run/infisical/paperless-ngx.env (Task A2/C1), Host-Verzeichnisse aus Task C2 - Produces: Container paperless_ngx, paperless_db, paperless_redis, gotenberg, tika, protonmail-bridge; Profil init mit protonmail-bridge-init (Task E2); Consume-Pfad /mnt/ssd/container-data/paperless/consume (Task B2 hängt die Freigabe an …/consume/robin)

  • Step 1: Datei mit folgendem Inhalt überschreiben
# Paperless-ngx 3.1 — E7 Greenfield (Design: homelab-documentation/docs/planung/2026-09-e7/).
#
# Secrets (PAPERLESS_SECRET_KEY, POSTGRES_PASSWORD, PAPERLESS_DBPASS) kommen aus
# /run/infisical/paperless-ngx.env (infisical-agent). Alles andere steht hier.
networks:
  proxy_network:
    external: true
  paperlessngx_internal:
    driver: bridge
    name: paperlessngx_internal

services:
  paperless_ngx:
    image: ghcr.io/paperless-ngx/paperless-ngx:3.1.3
    container_name: paperless_ngx
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - proxy_network
      - paperlessngx_internal
    depends_on:
      paperless_db:
        condition: service_healthy
      paperless_redis:
        condition: service_healthy
      gotenberg:
        condition: service_started
      tika:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s
    volumes:
      - /mnt/ssd/container-data/paperless/data:/usr/src/paperless/data
      - /mnt/ssd/container-data/paperless/consume:/usr/src/paperless/consume
      - /mnt/paperless/media:/usr/src/paperless/media
      - /mnt/paperless/export:/usr/src/paperless/export
      - /mnt/paperless/trash:/usr/src/paperless/trash
    env_file:
      - /run/infisical/paperless-ngx.env
    environment:
      # --- Core ---
      PAPERLESS_URL: "https://paperless.home.robinwerner.net"
      PAPERLESS_APP_TITLE: "Paperless"
      PAPERLESS_TIME_ZONE: "Europe/Berlin"
      USERMAP_UID: "977"
      USERMAP_GID: "988"
      # --- Redis / Datenbank (v3: DBENGINE ist Pflicht) ---
      PAPERLESS_REDIS: "redis://paperless_redis:6379"
      PAPERLESS_DBENGINE: "postgresql"
      PAPERLESS_DBHOST: "paperless_db"
      PAPERLESS_DBPORT: "5432"
      PAPERLESS_DBNAME: "paperless"
      PAPERLESS_DBUSER: "paperless"
      PAPERLESS_DB_OPTIONS: "connect_timeout=30"
      PAPERLESS_DB_READ_CACHE_ENABLED: "true"
      # --- Tika / Gotenberg (Mail als PDF, Office-Anhänge) ---
      PAPERLESS_TIKA_ENABLED: "1"
      PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
      PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
      # --- OCR (v3: 'skip' entfällt; Archiv immer, damit documents/archive vollständig ist) ---
      PAPERLESS_OCR_LANGUAGE: "deu+eng"
      PAPERLESS_OCR_MODE: "auto"
      PAPERLESS_ARCHIVE_FILE_GENERATION: "always"
      PAPERLESS_OCR_CLEAN: "clean"
      PAPERLESS_OCR_DESKEW: "true"
      PAPERLESS_OCR_ROTATE_PAGES: "true"
      PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD: "12"
      PAPERLESS_OCR_OUTPUT_TYPE: "pdfa"
      # --- Performance (NUC i5-1135G7, 4C/8T, teilt sich den Host mit ~60 Containern) ---
      PAPERLESS_TASK_WORKERS: "2"
      PAPERLESS_THREADS_PER_WORKER: "2"
      PAPERLESS_WEBSERVER_WORKERS: "2"
      PAPERLESS_CONVERT_MEMORY_LIMIT: "512"
      PAPERLESS_CONVERT_TMPDIR: "/tmp/paperless"
      # --- Consumer: consume/<eigentuemer>/ ; Unterordner ist KEIN Tag ---
      PAPERLESS_CONSUMER_RECURSIVE: "true"
      PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS: "false"
      PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
      PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: "false"
      # --- Barcodes: ASN setzen UND an jedem ASN-Etikett ein neues Dokument beginnen ---
      PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
      PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
      PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX: "ASN"
      PAPERLESS_CONSUMER_BARCODE_MAX_PAGES: "0"
      # --- Dateinamen: Auffang, wenn kein Speicherpfad gesetzt ist (Spec Abschnitt 5) ---
      PAPERLESS_FILENAME_FORMAT: >-
        {% if owner_username %}{{ owner_username }}/{% endif %}_Unsortiert/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}
      PAPERLESS_FILENAME_DATE_ORDER: "DMY"
      PAPERLESS_DATE_ORDER: "DMY"
      PAPERLESS_TRASH_DIR: "/usr/src/paperless/trash"
      PAPERLESS_EMPTY_TRASH_DELAY: "30"
      # --- Sicherheit (v3: TRUSTED_PROXIES gegen Login-403 hinter Traefik; Präfix-Match, kein CIDR) ---
      PAPERLESS_ALLOWED_HOSTS: "paperless.home.robinwerner.net,localhost,127.0.0.1"
      PAPERLESS_CSRF_TRUSTED_ORIGINS: "https://paperless.home.robinwerner.net"
      PAPERLESS_TRUSTED_PROXIES: "10.100."
      PAPERLESS_COOKIE_PREFIX: "paperless_"
      PAPERLESS_ACCOUNT_SESSION_REMEMBER: "true"
      PAPERLESS_SESSION_COOKIE_AGE: "2592000"
      PAPERLESS_AUDIT_LOG_ENABLED: "true"
      # --- KI (native v3, openai-like gegen llama-swap auf dem aiserver) ---
      PAPERLESS_AI_ENABLED: "true"
      PAPERLESS_AI_LLM_BACKEND: "openai-like"
      PAPERLESS_AI_LLM_ENDPOINT: "http://10.10.10.7:9292/v1"
      PAPERLESS_AI_LLM_MODEL: "brain-llamacpp"
      PAPERLESS_AI_LLM_API_KEY: "none"
      PAPERLESS_AI_LLM_CONTEXT_SIZE: "16384"
      PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
      PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
      PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
      PAPERLESS_AI_LLM_EMBEDDING_MODEL: "embed-llamacpp"
      PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
      PAPERLESS_LLM_INDEX_TASK_CRON: "30 3 * * *"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.paperless.rule=Host(`paperless.home.robinwerner.net`)"
      - "traefik.http.routers.paperless.entrypoints=websecure"
      - "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
      - "traefik.http.services.paperless.loadbalancer.server.port=8000"

  paperless_redis:
    image: docker.io/library/redis:8.10.1
    container_name: paperless_redis
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - paperlessngx_internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    volumes:
      - /mnt/ssd/container-data/paperless/redis:/data

  paperless_db:
    image: docker.io/library/postgres:18.6
    container_name: paperless_db
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - paperlessngx_internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 30s
    volumes:
      - /mnt/ssd/container-data/paperless/db:/var/lib/postgresql
    # POSTGRES_PASSWORD kommt aus Infisical und greift NUR bei leerem Datadir.
    env_file:
      - /run/infisical/paperless-ngx.env
    environment:
      POSTGRES_DB: "paperless"
      POSTGRES_USER: "paperless"
      POSTGRES_INITDB_ARGS: "--encoding=UTF8"
    command:
      - "postgres"
      - "-c"
      - "shared_buffers=256MB"
      - "-c"
      - "effective_cache_size=512MB"
      - "-c"
      - "work_mem=16MB"
      - "-c"
      - "maintenance_work_mem=128MB"
      - "-c"
      - "random_page_cost=1.1"

  gotenberg:
    image: docker.io/gotenberg/gotenberg:8.37.0
    container_name: gotenberg
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - paperlessngx_internal
    command:
      - "gotenberg"
      - "--chromium-disable-javascript=true"
      - "--chromium-allow-list=file:///tmp/.*"

  tika:
    image: docker.io/apache/tika:3.3.1.0
    container_name: tika
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - paperlessngx_internal

  # Proton Mail Bridge, headless. Nur im internen Netz, keine Ports nach außen:
  # Paperless spricht IMAP auf protonmail-bridge:1243 (socat -> 127.0.0.1:1143).
  # Image läuft als Benutzer protonmail (UID 1000), Daten unter /data.
  # Einmalige Anmeldung (2FA) über das Profil "init", siehe docs.md.
  protonmail-bridge:
    image: ghcr.io/enucatl/protonmail-bridge:3.26.0
    container_name: protonmail-bridge
    restart: unless-stopped
    command: ["run"]
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    networks:
      - paperlessngx_internal
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m,mode=0700,uid=1000,gid=1000
    volumes:
      - /mnt/ssd/container-data/paperless/protonmail-bridge:/data

  protonmail-bridge-init:
    image: ghcr.io/enucatl/protonmail-bridge:3.26.0
    container_name: protonmail-bridge-init
    command: ["init"]
    profiles:
      - init
    stdin_open: true
    tty: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    networks:
      - paperlessngx_internal
    volumes:
      - /mnt/ssd/container-data/paperless/protonmail-bridge:/data
  • Step 2: Keine Interpolation, kein paperless.env mehr

Run: grep -nE '\$\{|paperless\.env|:-paperless' paperless-ngx/docker-compose.yaml || echo CLEAN Expected: CLEAN

  • Step 3: YAML-Lint

Run: yamllint -d relaxed paperless-ngx/docker-compose.yaml Expected: keine error-Zeilen

  • Step 4: Compose-Syntax lokal prüfen (die env_file existiert nur auf dem NUC, deshalb per Stdin auf eine leere Datei umgebogen)
touch /tmp/paperless-ngx.env
sed 's#/run/infisical/paperless-ngx.env#/tmp/paperless-ngx.env#' paperless-ngx/docker-compose.yaml \
  | docker compose --project-directory paperless-ngx -f - config --quiet && echo CONFIG_OK

Expected: CONFIG_OK (Warnung „network proxy_network declared as external" ist lokal normal)

  • Step 5: Commit
git add paperless-ngx/docker-compose.yaml
git commit -m "feat(paperless): rebuild stack on paperless-ngx 3.1 with native AI and Proton Bridge"

Task A4: Geleakte paperless.env entfernen, CLAUDE.md nachziehen

Files: - Delete: paperless-ngx/paperless.env - Modify: CLAUDE.md

  • Step 1: Datei aus Git und Working-Tree entfernen
git rm paperless-ngx/paperless.env
test -e paperless-ngx/paperless.env && echo STILL_THERE || echo REMOVED

Expected: REMOVED

  • Step 2: CLAUDE.md, Abschnitt „Required Environment Files": die Zeile - `paperless-ngx/docker-compose.env`: Paperless-NGX settings löschen.

  • Step 3: CLAUDE.md, Abschnitt „Security Considerations": - Migrated stacks (`ollama`, `speedtest-tracker`) use the Infisical Agent → - Migrated stacks (`ollama`, `speedtest-tracker`, `paperless-ngx`) use the Infisical Agent

  • Step 4: Prüfen

Run: grep -n 'docker-compose.env\|paperless-ngx)' CLAUDE.mdExpected: genau eine Trefferzeile (die neue Liste der migrierten Stacks), keine mitdocker-compose.env`

  • Step 5: Commit
git add CLAUDE.md paperless-ngx/paperless.env
git commit -m "chore(paperless): drop tracked env file with leaked secret key"

Task A5: paperless-ngx/docs.md neu schreiben

Files: - Modify: paperless-ngx/docs.md (vollständig ersetzen)

  • Step 1: Inhalt
# Paperless-ngx

Dokumentenmanagement mit OCR, Volltextsuche, ASN-Barcodes und nativer KI. [GitHub](https://github.com/paperless-ngx/paperless-ngx)
Design und Umsetzungsplan (E7): `homelab-documentation/docs/planung/2026-09-e7/`.

## Zugriff

| | |
|---|---|
| URL | `https://paperless.home.robinwerner.net` (nur intern) |
| Netzwerk | `proxy_network`, `paperlessngx_internal` |
| Traefik | Ja |
| Konten | lokal (Robin); SSO folgt in E8 |

## Stack

| Container | Image | Zweck |
|---|---|---|
| `paperless_ngx` | `paperless-ngx:3.1.3` | App, Consumer, KI-Client |
| `paperless_db` | `postgres:18.6` | Datenbank (SSD) |
| `paperless_redis` | `redis:8.10.1` | Broker |
| `gotenberg` | `gotenberg:8.37.0` | Mail/Office → PDF |
| `tika` | `tika:3.3.1.0` | Textextraktion |
| `protonmail-bridge` | `enucatl/protonmail-bridge:3.26.0` | IMAP-Brücke zu Proton, nur intern (Port 1243) |

## Konfigurationsbesonderheiten

- **Secrets über den Infisical-Agent:** `PAPERLESS_SECRET_KEY`, `POSTGRES_PASSWORD`, `PAPERLESS_DBPASS` liegen unter
  `/paperless-ngx` und werden nach `/run/infisical/paperless-ngx.env` gerendert (`env_file`). Keine Secrets im Repo.
- **v3-Pflichten:** `PAPERLESS_DBENGINE=postgresql`, `OCR_MODE=auto`, `ARCHIVE_FILE_GENERATION=always` (jedes Dokument
  bekommt ein Archiv-PDF, sonst wäre `documents/archive/` unvollständig), `TRUSTED_PROXIES=10.100.0.0/16` gegen
  Login-403 hinter Traefik.
- **Zulauf Papier:** HP 4302 → Samba-Freigabe `[paperless]` → `consume/robin/`. Unterordner = Eigentümer (kein Tag).
  ASN-Barcodes (`ASN00042`) setzen die Archivnummer **und** trennen den Stapel: an jeder Seite mit Etikett beginnt ein
  neues Dokument, die Seite bleibt erhalten. Doppelte ASN → Dokument wird abgewiesen (Aufgaben-Seite).
- **Zulauf Mail:** Proton-Label „Paperless" (von Hand oder per Proton-Filter) → Mail-Regel liest `Labels/Paperless`
  über die Bridge, PDF-Anhänge werden Dokumente (Eigentümer Robin, Tags `Inbox` + `E-Mail`). Verarbeitete Mails merkt
  sich Paperless per UID (*Mail → Verarbeitete E-Mails*).
- **Ordnerlogik:** Speicherpfade je Lebensbereich, erste Ebene ist der Eigentümer, `_Unsortiert` als Auffang
  (`PAPERLESS_FILENAME_FORMAT`). Dateien werden nur über Metadaten verschoben, nie von Hand.
- **KI:** `openai-like` gegen llama-swap `10.10.10.7:9292` — `brain-llamacpp` für Vorschläge, `embed-llamacpp` für
  den RAG-Index (täglich 03:30). Workflow „Dokument hinzugefügt → KI-Vorschläge anwenden" setzt Titel, Datum,
  Korrespondent (darf anlegen), Typ, Speicherpfad und Tags (nur bestehende). **Die KI-Felder in Einstellungen →
  Anwendungskonfiguration bleiben leer**, sonst schlägt die Datenbank die Compose-Datei.
- **Postgres-Tuning:** `shared_buffers=256MB`, `effective_cache_size=512MB`, `work_mem=16MB`.
- **NFS:** Media/Export/Trash liegen auf `/mnt/paperless` (UNAS). UID/GID 977/988 = Systembenutzer `paperless` auf dem
  NUC = Container-Identität = UNAS-Export.

## Host-Bausteine (`host-config/`, `scripts/`)

| Datei | Zweck |
|---|---|
| `host-config/smb-paperless.conf` | Freigabe `[paperless]`, per `include` in `/etc/samba/smb.conf` |
| `host-config/paperless-export.{service,timer}` | `document_exporter` täglich 01:15 nach `/mnt/paperless/export` |
| `host-config/paperless-consume-collector.{service,timer}` | alle 15 min `paperless.prom` für den Textfile-Collector |
| `scripts/consume-stale-collector.sh` | zählt Dateien > 30 min im Consume-Ordner |
| `seed/schema.yaml`, `seed/seed.py` | Startschema, idempotent über die REST-API |

Dazu setzt das Runbook (Plan Teil C) zwei globale Samba-Werte von Hand: `server min protocol = SMB2` und
`map to guest = Never`; die `include`-Zeile für die Freigabe muss die letzte Zeile von `[global]` sein.

Installation der Units (NUC, root):

```bash
cp /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.service \
   /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now paperless-export.timer paperless-consume-collector.timer
```

## Ersteinrichtung

```bash
# Superuser nach dem ersten Start (Passwort in den Passwortmanager)
docker exec -it paperless_ngx python3 manage.py createsuperuser

# Proton Bridge einmalig anmelden (2FA). Vorher den Run-Container stoppen, er hält den
# Single-Instance-Lock unter /data. Danach: login → info → exit
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml stop protonmail-bridge
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml --profile init run --rm protonmail-bridge-init
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml up -d protonmail-bridge

# Startschema (vom Laptop; Token aus Mein Profil → API-Auth-Token)
PAPERLESS_URL=https://paperless.home.robinwerner.net PAPERLESS_TOKEN=… uv run paperless-ngx/seed/seed.py --dry-run
```

Nach dem ersten Login die UI-Sprache auf **Deutsch** stellen (KI-Vorschläge folgen ihr).

## Backup / Restore

- `/mnt/ssd/container-data` (DB, Index, Consume, Bridge) und `/mnt/paperless` (Media, Export, Trash) sind in beiden
  borgmatic-Ketten; der DB-Dump greift automatisch (`predeploy-backup.sh`).
- Der nächtliche Export ist Paperless' eigenes Sicherungsformat (Originale, Archive, Manifest mit allen Metadaten) und
  lässt sich mit `document_importer` in eine leere Instanz zurückspielen. Nachweis:
  `borgmatic list --archive latest --path mnt/paperless/export` (nicht `--find`).
- Restore-Probe: E7b.

## Wichtige Pfade

| Pfad | Zweck |
|---|---|
| `/mnt/ssd/container-data/paperless/data` | Index, Klassifikator, LLM-Index |
| `/mnt/ssd/container-data/paperless/db` | Postgres |
| `/mnt/ssd/container-data/paperless/redis` | Redis |
| `/mnt/ssd/container-data/paperless/consume/robin` | Zulauf (Samba `[paperless]`) |
| `/mnt/ssd/container-data/paperless/protonmail-bridge` | Bridge-Vault und Mail-Cache (UID 1000) |
| `/mnt/paperless/media` | Dokumente (UNAS) |
| `/mnt/paperless/export` | nächtlicher Export (UNAS) |
| `/mnt/paperless/trash` | Papierkorb (UNAS) |
| `/mnt/ssd/container-data/monitoring-stack/textfile/paperless.prom` | Consume-Metriken |

## Offen (E7b)

Dropbox-Spiegel · Restore-Probe · Lars' Konto und Eigentümertrennung · SSO (E8) · Vision-OCR über paperless-gpt,
falls die OCR-Schwelle reißt · „Mail als PDF" in der Mail-Regel · Custom Fields
  • Step 2: Commit
git add paperless-ngx/docs.md
git commit -m "docs(paperless): describe the E7 stack, feeds, AI and host units"

Task A6: PR-A öffnen

Files: keine

  • Step 1: Push + PR (Body als Datei im Scratchpad, nur beschreiben, was im Diff ist)
git push -u origin feat/paperless-v3-e7
gh pr create --base main --title "feat(paperless): E7 rebuild on paperless-ngx 3.1 with native AI and Proton Bridge" --body-file <scratchpad>/pr-a.md

Inhalt von pr-a.md: Stack auf 3.1.3 neu, v3-Migration der Einstellungen, native KI gegen llama-swap, Bridge-Container mit Init-Profil, Secrets über Infisical-Agent, getrackte paperless.env entfernt, docs.md neu. Deploy-Hinweis: Teil C dieses Plans (NUC-Schritte durch Robin). Abschluss mit der Attributionszeile aus der Session.

  • Step 2: Prüfen

Run: gh pr view --json url,state -q '.url + " " + .state' Expected: URL + OPEN


Teil B — Repo: Host-Bausteine, Seed, Monitoring (Branch feat/paperless-v3-host)

Task B1: Arbeits-Branch

  • Step 1
cd /home/robin/repos/homeserver
git switch -c feat/paperless-v3-host origin/main
mkdir -p paperless-ngx/host-config paperless-ngx/scripts paperless-ngx/seed/tests
  • Step 2: Ignore-Regeln prüfen

Run: git check-ignore -v paperless-ngx/host-config/x paperless-ngx/scripts/x paperless-ngx/seed/x; echo "exit=$?" Expected: keine Ausgabe, exit=1 (nichts ist ignoriert)


Task B2: Samba-Freigabe als Include-Datei

Files: - Create: paperless-ngx/host-config/smb-paperless.conf

Interfaces: - Consumes: Systembenutzer/-gruppe paperless (977/988) und Verzeichnis consume/robin (Task C2) - Produces: Freigabe \\10.10.10.3\paperless für Quick Set „Paperless" (Task D4)

  • Step 1: Datei anlegen
; Paperless consume share for the HP 4302 (E7, homeserver#190).
; Included from /etc/samba/smb.conf via
;   include = /opt/rpi-docker-compose/paperless-ngx/host-config/smb-paperless.conf
; Put that include as the LAST line of [global]: the section header below
; switches the current section for everything that follows.
; Files are forced to the paperless identity (UID 977 / GID 988) so the
; consumer inside the container can read and delete them without any
; permission juggling. The scanner authenticates as the existing user
; "scanner" — the device stores exactly one credential per Quick Set.
[paperless]
   comment = Paperless consume (Robin)
   path = /mnt/ssd/container-data/paperless/consume/robin
   valid users = scanner
   force user = paperless
   force group = paperless
   create mask = 0660
   directory mask = 0770
   browseable = no
   guest ok = no
   writable = yes
  • Step 2: Commit
git add paperless-ngx/host-config/smb-paperless.conf
git commit -m "feat(paperless): samba share definition for the scanner consume folder"

Task B3: Exporter-Timer

Files: - Create: paperless-ngx/host-config/paperless-export.service - Create: paperless-ngx/host-config/paperless-export.timer

Interfaces: - Consumes: Container paperless_ngx (Task A3), Mount /mnt/paperless/export - Produces: Exportbaum /mnt/paperless/export/{originals,archive,thumbnails,json} + manifest.json (Task F4 prüft)

  • Step 1: Service
[Unit]
Description=Export Paperless documents and metadata to /mnt/paperless/export
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190
# Runs inside the paperless_ngx container, so Docker has to be up.
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
# document_exporter is Paperless' own backup format: originals, archive PDFs
# and a manifest with the complete database content (tags, correspondents,
# ASN, workflows, mail rules, permissions). It is restorable into an empty
# instance regardless of Postgres version or paths — the pg_dump is not.
#   --delete            mirror the current state, drop files of deleted docs
#   --use-folder-prefix originals/ archive/ thumbnails/ json/
#   --split-manifest    one json per document, so borg deduplicates changes
ExecStart=/usr/bin/docker exec paperless_ngx document_exporter ../export --delete --use-folder-prefix --split-manifest --no-progress-bar
# First run exports everything; later runs only write what changed.
TimeoutStartSec=3h
  • Step 2: Timer
[Unit]
Description=Nightly Paperless export, 49 minutes before the borgmatic run
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190

[Timer]
# borgmatic starts at 02:04; the export has to be finished by then so the
# night's archive carries the current manifest.
OnCalendar=*-*-* 01:15:00
AccuracySec=1min
# No Persistent=true on purpose: a catch-up run right after a late boot
# could overlap the borg run. Missing one night's export costs nothing,
# the previous export is still in the archive.

[Install]
WantedBy=timers.target
  • Step 3: Syntax prüfen (lokal; systemd-analyze prüft nur die Unit-Grammatik)

Run: systemd-analyze verify paperless-ngx/host-config/paperless-export.service paperless-ngx/host-config/paperless-export.timer Expected: keine Ausgabe (Warnung „Unit is not loaded" darf erscheinen)

  • Step 4: Commit
git add paperless-ngx/host-config/paperless-export.service paperless-ngx/host-config/paperless-export.timer
git commit -m "feat(paperless): nightly document_exporter timer ahead of the borg run"

Task B4: Consume-Stau-Collector

Files: - Create: paperless-ngx/scripts/consume-stale-collector.sh - Create: paperless-ngx/host-config/paperless-consume-collector.service - Create: paperless-ngx/host-config/paperless-consume-collector.timer

Interfaces: - Produces: Metriken paperless_consume_stale_files, paperless_consume_files, paperless_consume_collector_timestamp_seconds in /mnt/ssd/container-data/monitoring-stack/textfile/paperless.prom (Task B5 alarmiert darauf)

  • Step 1: Skript
#!/usr/bin/env bash
# Files waiting in the Paperless consume directory as Prometheus gauges.
#
# The consume directory is the one place where a document can get lost
# silently: a file the consumer never picks up just sits there, Paperless
# reports nothing, and the scan looks "done" at the device. With the old
# Syncthing feed that went unnoticed for nine months (E7, homeserver#190).
#
# Called every 15 minutes by paperless-consume-collector.timer. Dotfiles
# are ignored: macOS clients drop ._* and .DS_Store, and Paperless skips
# them too. The timestamp gauge lets Prometheus notice a dead collector.

set -euo pipefail

CONSUME_DIR=${CONSUME_DIR:-/mnt/ssd/container-data/paperless/consume}
TEXTFILE_DIR=${TEXTFILE_DIR:-/mnt/ssd/container-data/monitoring-stack/textfile}
MAX_AGE_MIN=${MAX_AGE_MIN:-30}
OUT="$TEXTFILE_DIR/paperless.prom"
TMP="$TEXTFILE_DIR/.paperless.prom.$$"

trap 'rm -f "$TMP"' EXIT

stale=$(find "$CONSUME_DIR" -type f ! -name '.*' -mmin +"$MAX_AGE_MIN" | wc -l)
total=$(find "$CONSUME_DIR" -type f ! -name '.*' | wc -l)

{
  echo "# HELP paperless_consume_stale_files Files older than ${MAX_AGE_MIN} minutes still waiting in the consume directory."
  echo "# TYPE paperless_consume_stale_files gauge"
  echo "paperless_consume_stale_files $stale"
  echo "# HELP paperless_consume_files Files currently in the consume directory."
  echo "# TYPE paperless_consume_files gauge"
  echo "paperless_consume_files $total"
  echo "# HELP paperless_consume_collector_timestamp_seconds Unix time of the last collector run."
  echo "# TYPE paperless_consume_collector_timestamp_seconds gauge"
  echo "paperless_consume_collector_timestamp_seconds $(date +%s)"
} >"$TMP"

# Atomic replace so node-exporter never reads a half-written file.
mv "$TMP" "$OUT"
  • Step 2: Lint und Format
chmod +x paperless-ngx/scripts/consume-stale-collector.sh
shellcheck paperless-ngx/scripts/consume-stale-collector.sh
shfmt -i 2 -d paperless-ngx/scripts/consume-stale-collector.sh

Expected: beide ohne Ausgabe

  • Step 3: Funktionstest mit Wegwerf-Verzeichnissen (eine alte, eine frische, eine Dotfile)
T=$(mktemp -d)
mkdir -p "$T/consume/robin" "$T/textfile"
touch -d '-2 hours' "$T/consume/robin/alt.pdf"
touch "$T/consume/robin/neu.pdf" "$T/consume/robin/._neu.pdf"
CONSUME_DIR="$T/consume" TEXTFILE_DIR="$T/textfile" paperless-ngx/scripts/consume-stale-collector.sh
grep -E '^paperless_consume_(stale_files|files) ' "$T/textfile/paperless.prom"
ls -a "$T/textfile"
rm -rf "$T"

Expected: paperless_consume_stale_files 1, paperless_consume_files 2, im Textfile-Verzeichnis nur paperless.prom (keine .paperless.prom.*-Reste)

  • Step 4: Gegenprobe, dass der Test scheitern kann — MAX_AGE_MIN=300 setzen und erneut ausführen: erwartet paperless_consume_stale_files 0. Danach Wegwerf-Verzeichnis löschen.

  • Step 5: Service (Härtung wie nut/host-config/ups-textfile-collector.service)

[Unit]
Description=Write Paperless consume-directory gauges to the node-exporter textfile directory
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190

[Service]
Type=oneshot
ExecStart=/opt/rpi-docker-compose/paperless-ngx/scripts/consume-stale-collector.sh

# The script reads one directory and writes one file. Nothing here needs
# privileges — same reasoning as the UPS collector (nut/host-config).
NoNewPrivileges=true
CapabilityBoundingSet=
# The consume tree is paperless:paperless 0770 (Samba directory mask); group
# membership is enough to traverse it, no capability needed.
SupplementaryGroups=paperless
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateNetwork=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_UNIX
ReadWritePaths=/mnt/ssd/container-data/monitoring-stack/textfile

# Four journal lines per run every 15 minutes is still noise; keep warnings.
LogLevelMax=warning
  • Step 6: Timer
[Unit]
Description=Collect Paperless consume-directory gauges every 15 minutes
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190

[Timer]
# The alert fires after 30 minutes of a stale file; 15 minutes gives two
# samples inside that window. Faster brings nothing — the consumer itself
# needs a few minutes for OCR on a long scan.
OnBootSec=5min
OnUnitActiveSec=15min
AccuracySec=1min

[Install]
WantedBy=timers.target
  • Step 7: Unit-Syntax

Run: systemd-analyze verify paperless-ngx/host-config/paperless-consume-collector.service paperless-ngx/host-config/paperless-consume-collector.timer Expected: keine Fehler („Unit is not loaded" und „ExecStart path does not exist" sind lokal erwartbar)

  • Step 8: Commit
git add paperless-ngx/scripts/consume-stale-collector.sh
git add paperless-ngx/host-config/paperless-consume-collector.service paperless-ngx/host-config/paperless-consume-collector.timer
git commit -m "feat(paperless): consume-directory gauges via node-exporter textfile collector"

Task B5: Alarmregeln

Files: - Create: monitoring-stack/configs/prometheus/rules/paperless-alerts.yml - Modify: monitoring-stack/configs/prometheus/rules/container-alerts.yml (Ausdruck von ContainerDown)

Interfaces: - Consumes: Metriken aus Task B4, container_last_seen von cAdvisor

  • Step 1: Neue Regeldatei
# Alert rules for the Paperless stack (E7, homeserver#190).
#
# Paperless itself never reports a file the consumer did not pick up. The
# gauges come from paperless-ngx/scripts/consume-stale-collector.sh, written
# every 15 minutes through the node-exporter textfile collector.
#
# Check both metrics against /api/v1/query after the first collector run
# before trusting these rules (silent-failure-alerts.yml explains why).
groups:
  - name: paperless-alerts
    rules:
      # WARNING: a document is waiting and nobody will notice otherwise.
      - alert: PaperlessConsumeStuck
        expr: paperless_consume_stale_files > 0
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Paperless: {{ $value }} Datei(en) hängen im Consume-Ordner"
          description: >
            Seit über 30 Minuten liegen Dateien in
            /mnt/ssd/container-data/paperless/consume, die der Consumer nicht
            abgeholt hat. Prüfen: docker logs paperless_ngx, Aufgaben-Seite in
            Paperless, Eigentümer der Datei (muss paperless/977 sein).

      # WARNING: the collector itself died. Without this rule a broken timer
      # would silence PaperlessConsumeStuck for good (same pattern as
      # AiserverBackupMetricMissing).
      - alert: PaperlessConsumeCollectorStale
        expr: >
          absent(paperless_consume_collector_timestamp_seconds) or
          (time() - paperless_consume_collector_timestamp_seconds > 3600)
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Paperless-Consume-Collector liefert seit über einer Stunde nichts"
          description: >
            paperless-consume-collector.timer auf dem NUC schreibt nicht mehr.
            Prüfen: systemctl status paperless-consume-collector.timer,
            journalctl -u paperless-consume-collector.
  • Step 2: paperless_ngx in ContainerDown aufnehmen — in container-alerts.yml nach der Zeile absent(container_last_seen{name="pihole"}) or einfügen:
          absent(container_last_seen{name="paperless_ngx"}) or
  • Step 3: Regeln prüfen (promtool aus dem gepinnten Prometheus-Image)
docker run --rm --entrypoint promtool \
  -v "$PWD/monitoring-stack/configs/prometheus/rules:/rules:ro" \
  prom/prometheus:v3.13.1 check rules /rules/paperless-alerts.yml /rules/container-alerts.yml

Expected: SUCCESS: 2 rules found und SUCCESS: 1 rules found

  • Step 4: Commit
git add monitoring-stack/configs/prometheus/rules/paperless-alerts.yml monitoring-stack/configs/prometheus/rules/container-alerts.yml
git commit -m "feat(monitoring): alert on stuck Paperless consume files and a dead collector"

Task B6: Seed-Skript mit Startschema (TDD)

Files: - Create: paperless-ngx/seed/schema.yaml - Create: paperless-ngx/seed/seed.py - Create: paperless-ngx/seed/tests/test_seed.py

Interfaces: - Consumes: REST-API GET/POST /api/{tags,document_types,storage_paths,correspondents}/, Header Authorization: Token <PAPERLESS_TOKEN> - Produces: Funktionen load_schema(path) -> dict[str, list[dict]], plan_missing(wanted, existing) -> list[dict], fetch_existing_names(session, base_url, endpoint) -> set[str], build_payload(section, item) -> dict, main(argv) -> int; CLI uv run paperless-ngx/seed/seed.py [--dry-run] [--schema PATH]

  • Step 1: Failing Tests schreiben (paperless-ngx/seed/tests/test_seed.py)
"""Tests for the Paperless taxonomy seed (pure logic, no network)."""

import pytest

import seed


def test_plan_missing_ignores_case():
    wanted = [{"name": "Inbox"}, {"name": "Steuer"}]
    assert seed.plan_missing(wanted, {"inbox"}) == [{"name": "Steuer"}]


def test_plan_missing_keeps_payload_fields():
    wanted = [{"name": "Arbeit", "path": "x/{{ title }}"}]
    assert seed.plan_missing(wanted, set()) == wanted


def test_load_schema_rejects_entry_without_name(tmp_path):
    schema = tmp_path / "schema.yaml"
    schema.write_text("tags:\n  - color: '#ff0000'\n", encoding="utf-8")
    with pytest.raises(ValueError, match="needs a 'name'"):
        seed.load_schema(schema)


def test_load_schema_fills_missing_sections(tmp_path):
    schema = tmp_path / "schema.yaml"
    schema.write_text("tags:\n  - name: Inbox\n", encoding="utf-8")
    loaded = seed.load_schema(schema)
    assert loaded["tags"] == [{"name": "Inbox"}]
    assert loaded["correspondents"] == []


def test_build_payload_defaults_matching_but_lets_item_override():
    assert seed.build_payload("correspondents", {"name": "HUK"}) == {
        "name": "HUK",
        "matching_algorithm": 6,
    }
    inbox = seed.build_payload("tags", {"name": "Inbox", "is_inbox_tag": True})
    assert inbox["matching_algorithm"] == 0
    assert inbox["is_inbox_tag"] is True


class FakeResponse:
    def __init__(self, payload):
        self._payload = payload

    def raise_for_status(self):
        return None

    def json(self):
        return self._payload


class FakeSession:
    def __init__(self, pages):
        self.pages = pages
        self.calls = []

    def get(self, url, timeout):
        self.calls.append(url)
        return FakeResponse(self.pages[url])


def test_fetch_existing_names_follows_pagination():
    base = "https://paperless.example"
    pages = {
        f"{base}/api/tags/?page_size=1000": {
            "results": [{"name": "Inbox"}],
            "next": f"{base}/api/tags/?page=2",
        },
        f"{base}/api/tags/?page=2": {"results": [{"name": "Steuer"}], "next": None},
    }
    session = FakeSession(pages)
    assert seed.fetch_existing_names(session, base, "/api/tags/") == {"Inbox", "Steuer"}
    assert len(session.calls) == 2


class FakePostResponse:
    def __init__(self, status_code, text):
        self.status_code = status_code
        self.text = text


class FakePostSession:
    """Fake session whose post() always answers with a fixed error response."""

    def __init__(self, status_code, text):
        self.status_code = status_code
        self.text = text

    def post(self, url, json, timeout):
        return FakePostResponse(self.status_code, self.text)


def test_create_items_raises_systemexit_with_server_body():
    session = FakePostSession(400, "Invalid variable detected.")
    with pytest.raises(SystemExit) as excinfo:
        seed.create_items(
            session, "https://paperless.example", "tags", [{"name": "Inbox"}]
        )
    message = str(excinfo.value)
    assert "/api/tags/" in message
    assert "Invalid variable detected." in message
  • Step 2: Tests laufen lassen, Scheitern bestätigen

Run: PYTHONPATH=paperless-ngx/seed uv run --with pytest==9.1.1 --with requests==2.34.2 --with pyyaml==6.0.3 pytest paperless-ngx/seed/tests -q Expected: ModuleNotFoundError: No module named 'seed'

  • Step 3: seed.py schreiben
# /// script
# requires-python = ">=3.11"
# dependencies = ["requests==2.34.2", "pyyaml==6.0.3"]
# ///
"""Seed the Paperless-ngx taxonomy: tags, document types, storage paths, correspondents.

Reads schema.yaml next to this file and creates every object that does not exist
yet (matched by name, case-insensitive). Existing objects are never changed, so
the script can run any number of times.

Usage:
    PAPERLESS_URL=https://paperless.home.robinwerner.net \\
    PAPERLESS_TOKEN=<api token> uv run paperless-ngx/seed/seed.py [--dry-run]
"""

from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path
from typing import Any

import requests
import yaml

SECTIONS: dict[str, str] = {
    "tags": "/api/tags/",
    "document_types": "/api/document_types/",
    "storage_paths": "/api/storage_paths/",
    "correspondents": "/api/correspondents/",
}
# Paperless matching algorithms: 0 = none, 6 = auto (learned classifier).
# Tags and storage paths are assigned by workflows and AI, not by matching.
DEFAULT_MATCHING: dict[str, int] = {
    "tags": 0,
    "document_types": 6,
    "storage_paths": 0,
    "correspondents": 6,
}


def load_schema(path: Path) -> dict[str, list[dict[str, Any]]]:
    """Load schema.yaml; every section becomes a list of dicts that carry a name."""
    data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    schema: dict[str, list[dict[str, Any]]] = {}
    for section in SECTIONS:
        items = data.get(section) or []
        if not isinstance(items, list):
            raise ValueError(f"{path}: section '{section}' must be a list")
        for item in items:
            if not isinstance(item, dict) or not item.get("name"):
                raise ValueError(f"{path}: every entry in '{section}' needs a 'name'")
        schema[section] = items
    return schema


def plan_missing(wanted: list[dict[str, Any]], existing: set[str]) -> list[dict[str, Any]]:
    """Return the wanted items whose name is not in existing (case-insensitive)."""
    known = {name.casefold() for name in existing}
    return [item for item in wanted if item["name"].casefold() not in known]


def fetch_existing_names(session: Any, base_url: str, endpoint: str) -> set[str]:
    """Collect the names behind a paginated Paperless list endpoint."""
    names: set[str] = set()
    url: str | None = f"{base_url}{endpoint}?page_size=1000"
    while url:
        response = session.get(url, timeout=30)
        response.raise_for_status()
        payload = response.json()
        names.update(result["name"] for result in payload["results"])
        url = payload.get("next")
    return names


def build_payload(section: str, item: dict[str, Any]) -> dict[str, Any]:
    """Turn a schema entry into the JSON body Paperless expects."""
    payload: dict[str, Any] = {"matching_algorithm": DEFAULT_MATCHING[section]}
    payload.update(item)
    return payload


def create_items(
    session: requests.Session, base_url: str, section: str, items: list[dict[str, Any]]
) -> int:
    """POST every item; stop at the first error with the server's answer."""
    endpoint = SECTIONS[section]
    for item in items:
        response = session.post(
            f"{base_url}{endpoint}", json=build_payload(section, item), timeout=30
        )
        if response.status_code >= 400:
            raise SystemExit(
                f"POST {endpoint} '{item['name']}' failed: "
                f"{response.status_code} {response.text}"
            )
        print(f"  + {section}: {item['name']}")
    return len(items)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--dry-run", action="store_true", help="only list what is missing")
    parser.add_argument(
        "--schema", type=Path, default=Path(__file__).with_name("schema.yaml")
    )
    args = parser.parse_args(argv)

    base_url = os.environ.get("PAPERLESS_URL", "").rstrip("/")
    token = os.environ.get("PAPERLESS_TOKEN", "")
    if not base_url or not token:
        print("PAPERLESS_URL and PAPERLESS_TOKEN must be set", file=sys.stderr)
        return 2

    schema = load_schema(args.schema)
    session = requests.Session()
    session.headers.update({"Authorization": f"Token {token}", "Accept": "application/json"})

    created = 0
    for section, endpoint in SECTIONS.items():
        existing = fetch_existing_names(session, base_url, endpoint)
        missing = plan_missing(schema[section], existing)
        print(f"{section}: {len(existing)} vorhanden, {len(missing)} fehlen")
        if args.dry_run:
            for item in missing:
                print(f"  ~ {section}: {item['name']}")
            continue
        created += create_items(session, base_url, section, missing)
    print(f"{created} Objekte angelegt")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
  • Step 4: Tests laufen lassen

Run: PYTHONPATH=paperless-ngx/seed uv run --with pytest==9.1.1 --with requests==2.34.2 --with pyyaml==6.0.3 pytest paperless-ngx/seed/tests -q Expected: 7 passed

  • Step 5: Lint

Run: uvx ruff check paperless-ngx/seed && uvx ruff format --check paperless-ngx/seed Expected: All checks passed! und „already formatted"

  • Step 6: schema.yaml (Korrespondenten trägt Robin ein, siehe Task D1)
# Startschema für Paperless (E7). seed.py legt fehlende Objekte an und lässt
# vorhandene unangetastet — der Name ist der Schlüssel.
#
# Speicherpfad-Vorlagen (Jinja): Eigentümer / Lebensbereich / Korrespondent /
# Jahr / "Datum Titel ASN00042". Die ASN im Dateinamen ist der Rückweg zum
# Papier. Fehlt eine Angabe, fällt ihr Segment weg statt "-none-" zu erzeugen.

tags:
  - name: Inbox
    color: "#6c757d"
    is_inbox_tag: true
  - name: Handeln
    color: "#dc3545"
  - name: Steuer
    color: "#ffc107"
  - name: Dauerhaft
    color: "#0d6efd"
  - name: E-Mail
    color: "#20c997"
  - name: Familie
    color: "#d63384"
  - name: Garantie
    color: "#fd7e14"

document_types:
  - name: Rechnung
  - name: Vertrag
  - name: Bescheid
  - name: Schreiben
  - name: Zeugnis/Urkunde
  - name: Gehaltsabrechnung
  - name: Kontoauszug
  - name: Versicherungspolice
  - name: Kündigung
  - name: Beleg/Quittung
  - name: Antrag/Formular
  - name: Steuerbescheid
  - name: Bestellung/Auftragsbestätigung
  - name: Sonstiges

storage_paths:
  - name: Arbeit
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Arbeit/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Finanzen
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Finanzen/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Versicherungen
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Versicherungen/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Wohnung
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Wohnung/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Gesundheit
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Gesundheit/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Behörden
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Behörden/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Studium & Zeugnisse
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Studium & Zeugnisse/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Fahrzeug
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Fahrzeug/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
  - name: Verträge & Abos
    path: "{% if owner_username %}{{ owner_username }}/{% endif %}Verträge & Abos/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"

# Regelmäßige Absender, Name wie im Briefkopf. Die KI legt weitere im Betrieb an.
correspondents: []
  • Step 7: Schema lädt

Run: PYTHONPATH=paperless-ngx/seed uv run --with pyyaml==6.0.3 --with requests==2.34.2 python3 -c "import seed, pathlib; s = seed.load_schema(pathlib.Path('paperless-ngx/seed/schema.yaml')); print({k: len(v) for k, v in s.items()})" Expected: {'tags': 7, 'document_types': 14, 'storage_paths': 9, 'correspondents': 0}

  • Step 8: Commit
git add paperless-ngx/seed/schema.yaml paperless-ngx/seed/seed.py paperless-ngx/seed/tests/test_seed.py
git commit -m "feat(paperless): idempotent taxonomy seed with the E7 start schema"

Task B7: PR-B öffnen

  • Step 1
git push -u origin feat/paperless-v3-host
gh pr create --base main --title "feat(paperless): host units, consume gauges, alert rules and taxonomy seed" --body-file <scratchpad>/pr-b.md

Body: die fünf Bausteine aus Teil B je ein Satz, Hinweis „Deploy: Teil C/F des Plans, Robins Hand". Attributionszeile.

  • Step 2: Prüfen

Run: gh pr view --json url,state -q '.url + " " + .state' Expected: URL + OPEN


Teil C — NUC-Cutover [ROBIN/NUC] (nach Merge von PR-A und PR-B)

Task C1: Infisical-Secrets und Agent-Template

  • Step 1: Werte lokal erzeugen (Laptop), nicht in den Chat kopieren
uv run python3 -c "import secrets; print('SECRET_KEY', secrets.token_urlsafe(64))"
openssl rand -hex 32   # DB-Passwort
  • Step 2: In der Infisical-UI (EU) unter Pfad /paperless-ngx, Env dev anlegen: PAPERLESS_SECRET_KEY, POSTGRES_PASSWORD, PAPERLESS_DBPASS (derselbe Wert wie POSTGRES_PASSWORD). Machine Identity nuc-agent Lese-Zugriff auf /paperless-ngx geben.

  • Step 3: [NUC root] Template-Block aus agent.yaml.example in /etc/infisical/agent.yaml übernehmen

ssh nuc-root
grep -n 'paperless-ngx' /etc/infisical/agent.yaml || echo "Block fehlt -> aus /opt/rpi-docker-compose/infrastructure/infisical-agent/agent.yaml.example übernehmen"
systemctl reload infisical-agent.service
sleep 5
cut -d= -f1 /run/infisical/paperless-ngx.env

Expected: drei Zeilen PAPERLESS_SECRET_KEY, POSTGRES_PASSWORD, PAPERLESS_DBPASS


Task C2: Systembenutzer und Verzeichnisse

  • Step 1: [NUC root] IDs frei?
getent passwd 977; getent group 988; echo "leer = frei"
  • Step 2: [NUC root] Benutzer anlegen
groupadd --gid 988 paperless
useradd --system --uid 977 --gid 988 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin paperless
id paperless

Expected: uid=977(paperless) gid=988(paperless) groups=988(paperless)

  • Step 3: [NUC root] Consume-Zweig und Bridge-Verzeichnis
install -d -o paperless -g paperless -m 0770 /mnt/ssd/container-data/paperless/consume/robin
install -d -o 1000 -g 1000 -m 0700 /mnt/ssd/container-data/paperless/protonmail-bridge
ls -ln /mnt/ssd/container-data/paperless/

Expected: consume (977/988), protonmail-bridge (1000/1000), daneben data, db, redis


Task C3: Samba

  • Step 1: [NUC root] SMB1-Clients prüfen (vor dem Anheben)
smbstatus -b
journalctl -u smbd --since '-30d' | grep -i 'NT1\|SMB1' | tail -5

Expected: keine aktiven NT1-Sitzungen. Der 4302 spricht SMB2/3, macOS ebenso.

  • Step 2: [NUC root] smb.conf anpassen — in [global]:
   server min protocol = SMB2
   map to guest = Never
   include = /opt/rpi-docker-compose/paperless-ngx/host-config/smb-paperless.conf

(server min protocol = NT1 und map to guest = Bad Password ersetzen.) Die include-Zeile muss die letzte Zeile von [global] sein: Samba fügt die Datei wörtlich ein, und ihr [paperless]-Header wechselt den Abschnitt für alles, was danach kommt. Globale Werte hinter dem Include landen sonst still in der Freigabe.

  • Step 3: [NUC root] Prüfen und laden
testparm -s 2>&1 | grep -A12 '^\[paperless\]'
testparm -s 2>&1 | grep -E 'server min protocol|map to guest'
testparm -s 2>&1 | grep -i 'global parameter'
systemctl reload smbd
smbclient -L //127.0.0.1 -U scanner

Expected: Freigabe paperless erscheint mit force user = paperless; server min protocol = SMB2; map to guest = Never; die Suche nach global parameter bleibt leer (sonst steht das Include zu früh); smbclient listet paperless nicht (browseable = no) aber scanner. Das Passwort des Benutzers scanner ist dasselbe wie im bestehenden Quick Set „Ablage".

  • Step 4: Schreibtest vom Laptop
echo test > /tmp/smbtest.txt
smbclient //10.10.10.3/paperless -U scanner -c 'put /tmp/smbtest.txt smbtest.txt; ls'

Dann [NUC]: ls -ln /mnt/ssd/container-data/paperless/consume/robin/ → smbtest.txt gehört 977/988, Rechte -rw-rw----. Datei danach löschen (rm), sonst konsumiert Paperless sie später als Dokument.


Task C4: Alten Stack ersetzen

  • Step 1: [NUC root] Repo aktualisieren, Rechte prüfen
cd /opt/rpi-docker-compose && git pull --ff-only
find /opt/rpi-docker-compose -type f ! -perm -o=r

Expected: nur traefik/.env und ts3/.env

  • Step 2: [NUC] Alten Stack stoppen
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml down
docker ps -a --filter name=paperless --filter name=gotenberg --filter name=tika

Expected: keine Container mehr

  • Step 3: [NUC root] Sicherheitsblick, dann leeren (DESTRUKTIV)
find /mnt/paperless/media -type f | wc -l     # erwartet: Dateien genau eines Testdokuments
ls /mnt/ssd/container-data/paperless/db
for d in /mnt/ssd/container-data/paperless/db /mnt/ssd/container-data/paperless/redis \
         /mnt/ssd/container-data/paperless/data /mnt/paperless/media /mnt/paperless/export /mnt/paperless/trash; do
  find "$d" -mindepth 1 -delete
done
find /mnt/paperless /mnt/ssd/container-data/paperless -mindepth 2 | wc -l

Expected: letzte Zeile 1 (nur consume/robin); consume/, protonmail-bridge/ und die Mount-Verzeichnisse bleiben stehen. /mnt/scanner wird nicht angefasst.

  • Step 4: [NUC] Konfiguration autoritativ prüfen und starten
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml config --quiet && echo OK
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml up -d
sleep 90
docker ps --filter name=paperless --filter name=gotenberg --filter name=tika --filter name=protonmail --format '{{.Names}}\t{{.Status}}'

Expected: paperless_ngx, paperless_db, paperless_redis mit (healthy); gotenberg, tika Up; protonmail-bridge Up (bis zur Anmeldung in Task E2 ohne Konto, aber gesund). protonmail-bridge-init läuft nicht (Profil).

  • Step 5: [NUC] Startlog auf v3-Warnungen prüfen

Run: docker logs paperless_ngx 2>&1 | grep -iE 'deprecat|removed|warning|error' | head -20 Expected: keine Zeile zu entfernten Einstellungen; KI-Verbindungsfehler dürfen hier noch nicht auftreten (erst bei der ersten Anfrage)


Task C5: Superuser, Sprache, Token

  • Step 1: [NUC] docker exec -it paperless_ngx python3 manage.py createsuperuser — Benutzername robin, Passwort in den Passwortmanager.

  • Step 2: Login unter https://paperless.home.robinwerner.net ohne 403 → Einstellungen → Sprache Deutsch.

  • Step 3: API-Token unter Mein Profil → API-Auth-Token erzeugen. Robin gibt ihn dem Seed nur als Umgebungsvariable (Task D1), nicht in den Chat.

  • Step 4: Anwendungskonfiguration kontrollieren — Einstellungen → Anwendungskonfiguration → Abschnitt KI: alle Felder leer lassen (die Compose-Datei ist die Wahrheit).


Teil D — Einrichtung in Paperless und am Scanner

Task D1: Startschema einspielen

  • Step 1: Robin ergänzt seine Absender in paperless-ngx/seed/schema.yaml (correspondents:), Name wie im Briefkopf, ein Eintrag je Zeile - name: …. Commit auf einem Branch chore/paperless-correspondents, PR, Merge.

  • Step 2: Trockenlauf vom Laptop

cd /home/robin/repos/homeserver && git pull --ff-only
PAPERLESS_URL=https://paperless.home.robinwerner.net PAPERLESS_TOKEN="$(cat ~/.paperless-token)" \
  uv run paperless-ngx/seed/seed.py --dry-run

Expected: je Abschnitt „0 vorhanden, N fehlen" mit den Namen aus dem Schema

  • Step 3: Einspielen, dann Idempotenz beweisen
PAPERLESS_URL=… PAPERLESS_TOKEN=… uv run paperless-ngx/seed/seed.py
PAPERLESS_URL=… PAPERLESS_TOKEN=… uv run paperless-ngx/seed/seed.py

Expected: erster Lauf legt 7 + 14 + 9 + N Objekte an; zweiter Lauf meldet überall „0 fehlen" und 0 Objekte angelegt. Paperless validiert jede Vorlage beim Anlegen mit einem Dummy-Dokument (ASN 12345). Die Vorlagen nutzen asn | int, weil Paperless die ASN als Text übergibt (Befund des Abschlussreviews 20.09., in der Jinja-Sandbox verifiziert). Ein 400 bei storage_paths wäre also ein neuer Fehler: Server-Body aus der Skriptausgabe lesen, nicht raten.

  • Step 4: Sichtprüfung in der UI: Tags (Inbox mit Inbox-Häkchen), Dokumenttypen, Speicherpfade (die Vorschau rendert mit demselben Dummy-Dokument und muss robin/Arbeit/…/… ASN12345 zeigen), Korrespondenten.

Task D2: Workflows (UI, Einstellungen → Workflows)

  • W1 „Scan von Robin" — Auslöser Verarbeitung gestartet, Filter Pfad */robin/* (Quelle: Consume-Ordner). Aktion Zuweisung: Eigentümer robin, Tag Inbox. Reihenfolge 1.

  • W2 „KI-Vorschläge anwenden" — Auslöser Dokument hinzugefügt, kein Filter. Aktion KI-Vorschläge anwenden: Felder Titel ✔, Tags ✔, Korrespondent ✔, Dokumenttyp ✔, Speicherpfad ✔, Erstelldatum ✔; Fehlende anlegen: nur Korrespondent; Vorhandene überschreiben: Titel und Erstelldatum. Reihenfolge 2.

  • Prüfen: beide Workflows aktiv, W1 vor W2.


Task D3: Gespeicherte Ansichten (UI, Dokumente → Filter → Ansicht speichern)

  • „Inbox" — Filter Tag Inbox; Seitenleiste ✔, Dashboard ✔.
  • „Handeln" — Filter Tag Handeln; Seitenleiste ✔, Dashboard ✔.
  • „Steuer laufendes Jahr" — Filter Tag Steuer und Erstellt ab 01.01.<Jahr>; Seitenleiste ✔.
  • „Unsortiert" — Filter Speicherpfad ohne; Seitenleiste ✔.

Task D4: Quick Set am HP 4302

  • Step 1: EWS öffnen (IP des Druckers, PIN von der Klappe oder eigenes Passwort) → Settings → Control Panel Customization → Quick Sets → + → Typ Scan to Network Folder, Name Paperless.
  • Step 2: Netzwerkpfad \\10.10.10.3\paperless, Benutzer scanner + Passwort (wie „Ablage"). Apply and Test muss grün werden.
  • Step 3: Datei-Einstellungen: Dateityp PDF, Farbe Farbe, Auflösung 300 dpi, Original beidseitig, Dateinamen-Präfix scan.
  • Step 4: Erster Scan — ein einseitiges Dokument mit ASN-Etikett. Erwartet: die Datei erscheint kurz in consume/robin/ ([NUC] ls -ln), verschwindet nach dem Import, das Dokument steht in der Inbox mit gesetzter ASN, Eigentümer Robin, und nach W2 mit Titel, Korrespondent, Typ, Speicherpfad. Prüfen unter Dokumente → Details und docker logs paperless_ngx --since 10m.
  • Step 5: Stapeltest — drei Dokumente mit je einem Etikett auf Seite 1 in einem Durchgang. Erwartet: drei Dokumente, drei ASN, richtige Seitenaufteilung.
  • Step 6: Nur falls Etiketten nicht erkannt werden (Log: kein ASN gefunden): in der Compose-Datei PAPERLESS_CONSUMER_BARCODE_DPI: "600" und PAPERLESS_CONSUMER_BARCODE_UPSCALE: "1.5" ergänzen (PR), up -d --force-recreate paperless_ngx, Schritt 4 wiederholen.

Teil E — Mail über Proton Bridge

Task E1: Proton vorbereiten (Robin)

  • Step 1: In Proton Mail ein Label „Paperless" anlegen (Seitenleiste → Labels → +).
  • Step 2: Filter für die regelmäßige Post: Einstellungen → Filter → Filter hinzufügen — Bedingung Absender enthält <Domain des Versicherers> (je ein Filter oder ein Filter mit mehreren Bedingungen), Aktion Label „Paperless". Kein „Verschieben". Für Einzelfälle setzt Robin das Label von Hand.
  • Step 3: Postfachgröße notieren (Einstellungen → Konto → Speicher), weil Bridge den ganzen Postkasten lokal cached (/mnt/ssd/container-data/paperless/protonmail-bridge).

Task E2: Bridge anmelden [ROBIN/NUC]

  • Step 1: Interaktive Anmeldung (2FA am Handy bereithalten). Vorher den Run-Container stoppen: Bridge hält einen Single-Instance-Lock unter /data, das beide Container teilen; mit laufendem protonmail-bridge bricht bridge --cli mit „running instance is newer than this one" ab.
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml stop protonmail-bridge
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml --profile init run --rm protonmail-bridge-init

Im Bridge-CLI: login → Proton-Adresse, Passwort, 2FA-Code → nach „Account … was added" info → Bridge-Passwort und Adresse notieren (Passwortmanager) → exit.

  • Step 2: Dienst neu starten und prüfen
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml up -d protonmail-bridge
sleep 60
docker ps --filter name=protonmail-bridge --format '{{.Names}}\t{{.Status}}'
docker logs protonmail-bridge --since 2m 2>&1 | grep -iE 'sync|error|logged' | tail -10

Expected: protonmail-bridge Up … (healthy); im Log der Beginn der Synchronisation, keine Fehler

  • Step 3: IMAP aus dem Paperless-Container erreichbar
docker exec paperless_ngx python3 -c "import socket; s=socket.create_connection(('protonmail-bridge',1243),5); print(s.recv(200)); s.close()"

Expected: eine Zeile, die mit b'* OK beginnt und Proton Mail Bridge enthält


Task E3: Mailkonto und Regel in Paperless (UI, Einstellungen → E-Mail)

  • Konto „Proton (Bridge)" — IMAP-Server protonmail-bridge, Port 1243, Sicherheit Keine (Verkehr bleibt im Docker-Netz), Benutzername = Proton-Adresse, Passwort = Bridge-Passwort aus info. Verbindung testen → grün.
  • Regel „Label Paperless → Anhänge" — Konto oben, Ordner Labels/Paperless, Maximales Alter 0, Anhangstyp Nur Anhänge, Dateinamensfilter *.pdf, Umfang Nur Anhänge verarbeiten; Zuweisungen: Titel aus Betreff, Korrespondent aus Absender (Name), Tags Inbox + E-Mail, Eigentümer robin. Aktion nach Verarbeitung Verschieben nach Labels/Paperless-erledigt (Label vorher in Proton anlegen; bei Bridge ist das ein Label-Tausch, die Mail bleibt im Posteingang. Eine Aktion „Nichts" gibt es nicht, „Als gelesen" holt nur ungelesene Mails, „Markieren" kollidiert mit Robins Sternen). Maximales Alter 0 = unbegrenzt.
  • Test: eine Mail mit PDF-Anhang mit Label „Paperless" versehen; spätestens nach 10 Minuten (oder Jetzt abrufen) steht das Dokument in der Inbox mit Tag E-Mail; unter E-Mail → Verarbeitete E-Mails erscheint die Nachricht; die Mail liegt weiter im Posteingang.

Teil F — Betrieb und Abnahme

Task F1: Timer und Collector [ROBIN/NUC]

  • Step 1: Units installieren
cp /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.service /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now paperless-export.timer paperless-consume-collector.timer
systemctl list-timers 'paperless-*'

Expected: beide Timer mit nächstem Lauf (Export 01:15, Collector in ≤ 15 min)

  • Step 2: Collector einmal von Hand, Wirkung prüfen
systemctl start paperless-consume-collector.service
cat /mnt/ssd/container-data/monitoring-stack/textfile/paperless.prom
docker exec prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=paperless_consume_collector_timestamp_seconds' | head -c 300

Expected: drei Gauges in der Datei; die Prometheus-Antwort enthält "result":[{ mit einem Wert (Metrik hat Serien)

  • Step 3: Export einmal von Hand
systemctl start paperless-export.service
ls /mnt/paperless/export
ls /mnt/paperless/export/json | head -3

Expected: archive json manifest.json originals thumbnails; je Dokument eine JSON-Datei


Task F2: Alarmregeln aktivieren [ROBIN/NUC]

  • Step 1: monitoring-stack liegt im selben Repo, git pull aus Task C4 hat die Regeln schon geholt. Reload:
docker exec prometheus wget -qO- --post-data='' http://localhost:9090/-/reload; echo "exit=$?"
docker exec prometheus wget -qO- http://localhost:9090/api/v1/rules | grep -o 'PaperlessConsume[A-Za-z]*' | sort -u

Expected: exit=0, Ausgabe PaperlessConsumeCollectorStale und PaperlessConsumeStuck

  • Step 2: Gegenprobe, dass die Metrik ausschlägt — [NUC root] (kein Dotfile, die zählt der Collector nicht)
touch -d '-45 minutes' /mnt/ssd/container-data/paperless/consume/robin/alerttest.txt
systemctl start paperless-consume-collector.service
docker exec prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=paperless_consume_stale_files' | grep -o '"value":\[[^]]*\]'
rm /mnt/ssd/container-data/paperless/consume/robin/alerttest.txt
systemctl start paperless-consume-collector.service

Expected: erst "value":[…,"1"], nach dem Löschen wieder "0". Die Datei zwischen den beiden Läufen zügig löschen: Paperless versucht sonst, sie zu konsumieren, und meldet auf der Aufgaben-Seite einen Fehler (was den Zulauf ebenfalls belegt, aber Aufräumen kostet).


Task F3: Kuma-Monitor (Robin, VPS-UI)

  • Uptime Kuma → Neuer Monitor → HTTP(s), Name „Paperless", URL https://paperless.home.robinwerner.net/, Intervall 60 s, erwarteter Status 200. Grün nach dem ersten Check.

Task F4: Nachtlauf-Nachweis (am Tag nach Teil C)

  • [NUC root]
/root/.local/bin/borgmatic list --archive latest --path mnt/paperless/export | head -5
/root/.local/bin/borgmatic list --archive latest --path var/lib/db-dumps | grep -i paperless
journalctl -u paperless-export.service --since yesterday | tail -3

Expected: Export-Pfade im Archiv, postgres-paperless_db.sql.gz im Dump-Verzeichnis, Export-Lauf 01:15 „Finished"


Task F5: Modell-Mini-Turnier (Spec 7.3)

  • Step 1: Fünf frisch importierte Dokumente wählen (Rechnung, Schreiben, Bescheid, Vertrag, Zeugnis). Mit brain-llamacpp (Compose-Stand) je Dokument Vorschlagen klicken, notieren: Titel brauchbar (j/n), Korrespondent richtig, Typ richtig, Speicherpfad richtig, Dauer bis zur Antwort.
  • Step 2: Einstellungen → Anwendungskonfiguration → KI → Modell json-llamacpp (vorübergehend), dieselben fünf Dokumente erneut Vorschlagen, gleiche Notizen.
  • Step 3: Feld wieder leeren (Compose bleibt die Wahrheit). Sieger nach Treffern je Feld, Latenz als Zweitkriterium. Verliert brain-llamacpp, Modell in der Compose-Datei per PR tauschen. Ergebnis als Tabelle ins Umsetzungs-Log.

Task F6: Abnahme (Spec 11) und OCR-Schwelle (Spec 7.4)

  • 20 echte Dokumente über Quick Set „Paperless", als Stapel mit Etiketten. Zählen: Trennung richtig, ASN gesetzt, wie viele ohne Handkorrektur richtig in Korrespondent/Typ/Speicherpfad (Ziel ≥ 15; gilt für den zweiten Stapel, der erste wird von Hand eingeordnet und der Index neu gebaut, Spec 7.2). Dazu auf der Platte: [NUC] ls -R /mnt/paperless/media/documents/archive/robin/ | head -40 zeigt den Ordnerbaum aus der Spec, und im Wurzelverzeichnis von archive/ liegt keine 0000042.pdf-artige Datei (das wäre der stille Rückfall, wenn eine Vorlage nicht rendert; die UI-Felder sähen trotzdem korrekt aus).
  • OCR-Schwelle: je Dokument einen Betrag oder ein Aktenzeichen über die Volltextsuche suchen. Unbrauchbar bei ≥ 2 von 20 → paperless-gpt nur für OCR nach E7b; sonst bleibt Tesseract. Ergebnis ins Log.
  • Mail: eine Mail mit PDF-Anhang landet automatisch (Task E3 bestanden).
  • Chat: Frage zu einem Dokument im Chat → Antwort mit Quellenlink (Index nach 03:30 vorhanden; Indexstand: docker exec paperless_ngx document_llmindex update).
  • Metriken/Monitor: paperless_consume_stale_files hat Serien, Kuma grün, Export im Archiv (F4).

Task F7: Dokumentation, Issues, Memory

  • homelab-documentation/docs/projects/homeserver/productivity/paperless-ngx.md auf den E7-Stand bringen (Stack-Tabelle, Zulauf Papier/Mail, Ordnerlogik, KI, Backup, Host-Units — Inhalt wie paperless-ngx/docs.md, dazu Link auf Design und Plan). Branch, PR, Merge.
  • Umsetzungs-Log je Session ergänzen (oben, Format wie die bestehenden Einträge): erledigte Tasks, Turnier- und Schwellenergebnis, Funde.
  • Issue #190 kommentieren (deutsch, Struktur: gebaut / Abweichungen / verschoben / Links). #87 schließen mit Verweis auf #190 (native KI statt Ollama-Sidecar). #242 bleibt offen für E7b.
  • E7b-Issue anlegen: Dropbox-Spiegel, Restore-Probe, Lars/Eigentümertrennung, Vision-OCR (falls Schwelle), „Mail als PDF", Custom Fields.
  • Memory homelab-roadmap-2026-08.md: E7-Abschnitt auf „gebaut" mit den Fakten, die man sonst neu suchen müsste (Bridge-Port 1243, Token-Pfad, Modell-Sieger, Schwellenergebnis).

Self-Review

Spec-Abdeckung: Abschnitt 3 Architektur → A3, C2, C4 · 4.1 Samba → B2, C3 · 4.2 Quick Sets → D4 · 4.3 ASN → A3, D4 · 4.4 Consumer/W1 → A3, D2 · 5 Ordnerlogik → A3 (FILENAME_FORMAT), B6 (storage_paths) · 6 Schema, Workflows, Ansichten → B6, D1, D2, D3 · 7 KI → A3, C5, F5, F6 · 8 Mail → A3 (Bridge), E1–E3 · 9 Secrets/v3 → A2, A3, A4, C1 · 10 Backup/Betrieb → B3, B4, B5, F1, F2, F3, F4 · 11 Abnahme → F6 · 12 E7b → F7 (Issue) · 13 Prüfpunkte → Image-Tags (Constraints), Bridge-Image (A3), format-Filter (D1 Schritt 3), Barcode-DPI (D4 Schritt 6), SMB1-Clients (C3 Schritt 1), Postfachgröße (E1 Schritt 3), Nachtlauf (F4).

Platzhalter: keine offenen TBD; einzige bewusst leere Stelle ist correspondents: [] in schema.yaml, die Robin in D1 Schritt 1 füllt (Daten, nicht Design).

Änderungen nach dem Abschlussreview (20.09.): Der Prüfpunkt „format-Filter" aus Spec 13 ist geklärt — der Filter existiert, aber Paperless übergibt asn als Text, deshalb überall format(asn | int) (A3, B6). Die Collector-Unit braucht SupplementaryGroups=paperless, weil der Consume-Baum 0770 ist (B4). Die Bridge-Anmeldung stoppt vorher den Run-Container (A5, E2). Die include-Zeile steht als letzte Zeile von [global], C3 prüft das mit grep -i 'global parameter'. PAPERLESS_TRUSTED_PROXIES ist ein Präfix (10.100.), kein CIDR (A3). Die Abnahme prüft den Ordnerbaum auf der Platte (F6). Ein siebter Seed-Test deckt den 400-Pfad von create_items ab (B6).

Konsistenz: Container-Namen paperless_ngx, paperless_db, paperless_redis, gotenberg, tika, protonmail-bridge, protonmail-bridge-init in A3, A5, C4, E2, F1 identisch · Bridge-Port 1243 in A3, A5, E2, E3 · Consume-Pfad …/consume/robin in A3, B2, C2, C3, D4 · Textfile-Datei paperless.prom in A5, B4, F1 · Metriknamen in B4 und B5 gleich · Infisical-Keys in A2, A5, C1 gleich · Timer-Namen in A5, B3, B4, F1 gleich.