Files
pdf-ocr-hotfolder/AI_AGENT_BRIEFING.md
T
techadmin 8da0b7da1c fix: veraPDF-FAIL respektiert original_on_success, Logverzeichnis raus (v0.4.1)
- Bei fehlgeschlagener veraPDF-Validierung wurde das Original bisher
  bedingungslos geloescht. Es folgt jetzt derselben
  [output].original_on_success-Regel wie im Erfolgsfall, damit "archive"
  das Original nicht ausgerechnet im Fehlerfall verliert.
- Das nie benutzte Logverzeichnis /var/log/pdf-ocr-hotfolder/ wird nicht
  mehr angelegt; der Dienst loggt ausschliesslich nach journald.
  README und Briefing nennen stattdessen die journalctl-Kommandos.
- 3 neue Tests (95 gesamt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:10:27 +02:00

14 KiB
Raw Blame History

AI Agent Briefing — PDF OCR Hotfolder

Zuletzt aktualisiert: 2026-09-22 Version: 0.4.1 Status: Multi-Instanz-Betrieb, Preflight-Checks und Fehlerzählung vorhanden, Test-Suite grün (95 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, nicht aus einem belegten Dauerbetrieb.

🎯 Projektziel

Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in durchsuchbare PDFs (optional PDF/A) umgewandelt und nach Wahl in einen Ordner / Nextcloud / per SFTP weitergegeben. Ersetzt das alte Bash-Tool pdf-tool (im Workspace).

📁 Projekt-Struktur

pdf-ocr-hotfolder/
├── pdf_ocr_hotfolder/
│   ├── __init__.py        # Versionsstring (__version__)
│   ├── __main__.py        # CLI (argparse: --config, --once, --version); Exit 0/1/2
│   ├── config.py          # TOML-Loader, Dataclasses, ConfigError
│   ├── service.py         # HotfolderService (watchdog + ThreadPool), Preflight, Zähler
│   ├── processor.py       # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
│   └── uploaders.py       # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
├── tests/                 # pytest-Suite (95 Tests, ocrmypdf wird gemockt)
│   ├── conftest.py            # Fixtures tmp_config / dummy_pdf
│   ├── test_config_errors.py
│   ├── test_error_counting.py
│   ├── test_ghostscript_version.py
│   ├── test_ocr_timeout.py
│   ├── test_once_exit_code.py
│   ├── test_output_naming.py
│   ├── test_preflight.py
│   └── test_upload_folder.py
├── systemd/
│   ├── pdf-ocr-hotfolder@.service  # Template-Unit (Instanz = %i)
│   └── lxc-compat.conf             # Drop-in-Vorlage: Hardening für LXC abschalten
├── pytest.ini             # testpaths = tests
├── config.example.toml
├── install.sh             # Interaktiver Installer + Instanz-Manager
├── update.sh              # Update aus Repo
├── requirements.txt
├── VERSION
├── CHANGELOG.md
└── README.md

🔧 Stack

Komponente Technologie
Sprache Python 3.11+ (für tomllib aus stdlib)
OCR ocrmypdf (als Library, nicht via Subprozess; Import ist lazy)
Engine Tesseract
Watcher watchdog
HTTP requests (Nextcloud WebDAV)
SFTP paramiko
Email smtplib (stdlib)
Tests pytest
Service systemd (Template-Unit)

🖥️ Installations-Layout (Multi-Instanz)

Pfad Inhalt
/opt/pdf-ocr-hotfolder/ Code + venv (für alle Instanzen gemeinsam)
/opt/pdf-ocr-hotfolder/.repo_path Pfad zum Repo, aus dem installiert wurde (nutzt update.sh)
/etc/pdf-ocr-hotfolder/<instanz>.toml Config pro Instanz (mode 640, root:)
/etc/systemd/system/pdf-ocr-hotfolder@.service Template-Unit
/etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf Drop-in für Container (optional)
/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf Drop-in für abweichenden User (optional)
/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/ Daten pro Instanz
/var/backups/pdf-ocr-hotfolder/ Update-Backups

Ein eigenes Logverzeichnis gibt es nicht (seit 0.4.1 auch nicht mehr vom Installer angelegt): _setup_logging() nutzt logging.basicConfig() ohne FileHandler, alles geht nach stdout → journald.

journalctl -u pdf-ocr-hotfolder@<instanz> -f          # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today     # alle Instanzen, heute

👤 Service-User

  • Basis-Install legt Default-User pdfocr an (als System-User, falls nicht schon vorhanden)
  • Beim Anlegen einer Instanz fragt der Installer nach dem Service-User (default pdfocr)
  • Wird ein abweichender User gewählt, wird ein systemd-Drop-in erstellt (pdf-ocr-hotfolder@<instanz>.service.d/user.conf) mit User=/Group= Override
  • Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via id -gn ermittelt
  • Bei AD-Usern mit lokaler UID werden Datei-Berechtigungen über die UID gesetzt — transparent

🗂️ Instanz-Management

install.sh ist gleichzeitig Installer und Instanz-Manager:

  • Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht)
  • Folgender Lauf: Basis-Install wird übersprungen (erkannt an venv + Template-Unit), bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden
  • Eingaben pro Instanz: Name ([a-z0-9][a-z0-9-]*), Basis-Pfad (default /var/lib/pdf-ocr-hotfolder/<name>), Service-User
  • Basis-Install prüft zusätzlich die Ghostscript-Version und bietet auf Debian 12 bookworm-backports an; erkennt Container (systemd-detect-virt --container) und bietet das LXC-Drop-in an
  • <instanz>.toml wird aus config.example.toml mit sed-substituierten Pfaden generiert
  • Instanz wird sofort enable --now gestartet

Manuelles Löschen einer Instanz:

systemctl disable --now pdf-ocr-hotfolder@<name>
rm /etc/pdf-ocr-hotfolder/<name>.toml
rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen

🔄 Update-Verhalten

update.sh:

  1. Findet das Repo (eigenes Verzeichnis oder /opt/pdf-ocr-hotfolder/.repo_path)
  2. Ermittelt alle aktiven pdf-ocr-hotfolder@*.service Units und stoppt sie
  3. Backup nach /var/backups/pdf-ocr-hotfolder/ (tar.gz, ohne venv/__pycache__)
  4. Kopiert Code + requirements + VERSION + config.example aus dem Repo
  5. pip install --upgrade im venv
  6. Aktualisiert Template-Unit + daemon-reload
  7. Setzt den Code-Eigentümer auf den User, dem venv gehört (default pdfocr)
  8. Startet alle zuvor aktiven Instanzen wieder, Exit 1 wenn eine nicht mehr hochkommt

Config-Dateien werden nie überschrieben. Das Repo muss erhalten bleiben — update.sh kopiert daraus.

⚙️ Konfiguration (Überblick)

Vollständiges Beispiel mit Kommentaren: config.example.toml. Sektionen:

Sektion Zweck
[paths] incoming, outgoing, working, error — Pflicht, fehlt einer → ConfigError + Exit 2
[ocr] languages, jobs, skip_text, oversample, pdfa_level, deskew, clean, max_workers, timeout (Sekunden pro Seite)
[output] name_mode (prefix/suffix/none), name_tag, original_on_success (delete/archive), archive_dir
[verapdf] enabled, binary, flavour — optionale PDF/A-Validierung per CLI
[upload.folder] enabled, target (leer = [paths].outgoing, dann No-op)
[upload.nextcloud] enabled, url, username, password, remote_path, verify_ssl
[upload.sftp] enabled, host, port, username, key_file, password, remote_path
[notify.email] enabled, SMTP-Daten, from_addr, to_addrs, on = always/errors/never
[logging] level = DEBUG/INFO/WARNING/ERROR

Unbekannte Keys in einer Sektion werden beim Laden still verworfen (config.py filtert gegen die Dataclass-Annotationen) — Tippfehler in Key-Namen fallen also nicht auf.

🔄 Verarbeitungs-Flow

Beim Start (run() wie run_once()), vor allem anderen:

  1. check_preflight() — tesseract und gs müssen im PATH sein; ist pdfa_level gesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft
  2. check_output_config() — validiert original_on_success, archive_dir (Pflicht bei archive) und name_mode
  3. Scheitert eines davon → PreflightError, CLI beendet sich mit Exit-Code 2 (ebenso bei kaputter/fehlender Config)

Pro Datei:

  1. watchdog triggert auf created/moved/closed in incoming/ (beim Start greift _scan_existing() bereits liegende PDFs auf)
  2. _wait_until_stable() wartet, bis die Datei nicht mehr wächst (max. ~60s)
  3. Move nach working/
  4. ocrmypdf.ocr() als Library-Call (kein Subprozess-Start pro PDF)
  5. Optional: veraPDF-Validierung (CLI-Subprozess) — bei FAIL geht das OCR-Ergebnis nach error/, das Original folgt original_on_success (wird also bei archive nicht gelöscht)
  6. Move nach outgoing/ unter dem laut [output] gebauten Namen (build_output_name(): prefix/suffix/none + name_tag — das harte OCR_-Präfix aus 0.1.0 ist nur noch der Default)
  7. Original in working/ wird laut original_on_success gelöscht oder nach archive_dir archiviert (Kollision → Timestamp-Suffix)
  8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
  9. E-Mail-Notify je nach [notify.email].on

Fehlerbehandlung (Stand 0.4.1):

Fehlerfall Zählt als Fehler Wo liegt die Datei danach
Stabilitäts-Check läuft in den Timeout ja bleibt in incoming/, wird beim nächsten Lauf erneut versucht
Datei verschwindet vor der Verarbeitung nein —
OCR wirft (ocrmypdf) ja error/
veraPDF FAIL ja OCR-Ergebnis nach error/, Original laut original_on_success (delete → weg, archive → archive_dir; seit 0.4.1)
Beliebige Exception aus process_pdf() (z.B. shutil.move nach outgoing/) ja _rescue_to_error() sucht in incoming/ und working/ und verschiebt nach error/
Mindestens ein Upload-Ziel schlägt fehl ja PDF bleibt bewusst in outgoing/ (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele

Der Service läuft in allen Fällen weiter (kein exit 1 wie im alten Bash-Tool). Im --once-Modus liefert die CLI Exit-Code 1, sobald error_count > 0 ist, sonst 0.

🧠 Performance-Entscheidungen

  • ocrmypdf als Library statt subprocess: spart Python-Interpreter-Start pro PDF
  • ThreadPool mit max_workers (default 2) — selbst wenn selten >1 PDF gleichzeitig kommt, blockiert ein langsamer Scan keinen schnellen
  • --jobs an ocrmypdf: Tesseract parallelisiert Seiten innerhalb eines PDFs
  • skip_text=True: bereits OCR-haltige Seiten werden nicht neu verarbeitet
  • Stabilitäts-Check statt magic-file new (alte Bash-Krücke)
  • upload_folder() nutzt shutil.copyfile() statt read_bytes()/write_bytes() — große PDFs landen nicht komplett im RAM
  • veraPDF nur wenn enabled=true (JVM-Start ist teuer)

⚠️ Fallstricke

  • Ghostscript 10.0.0–10.02.0 zerschießt OCR. Das ist der Debian-12-Default. In Kombination aus [ocr].pdfa_level + skip_text = true blockiert ocrmypdf komplett (Issue #3). Deshalb ist pdfa_level = "" der sichere Default, und der Preflight bricht mit Exit 2 ab, wenn pdfa_level gesetzt und die GS-Version betroffen ist. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an).
  • [ocr].timeout ist ein Timeout PRO SEITE, kein Gesamt-Timeout pro PDF. Der Wert geht als tesseract_timeout (ocrmypdf-Option --tesseract-timeout) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default 1800 in einer Config stehen hat, gibt Tesseract 30 Minuten je Seite — Richtwert ist 300. Ein durchgereichtes 0 würde ocrmypdf dazu bringen, OCR still zu überspringen, deshalb wird bei 0 (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.
  • systemd-Hardening bricht in LXC-Containern (Error 226/NAMESPACE durch PrivateTmp, ProtectSystem usw., Issue #4). Gegenmittel ist das Drop-in systemd/lxc-compat.conf nach /etc/systemd/system/pdf-ocr-hotfolder@.service.d/; der Installer erkennt Container via systemd-detect-virt --container und bietet es an.
  • Das Paket wird nicht pip-installiert, sondern nach /opt/pdf-ocr-hotfolder kopiert. Gestartet wird per python -m pdf_ocr_hotfolder, gefunden wird das Modul nur über das Arbeitsverzeichnis — WorkingDirectory=/opt/pdf-ocr-hotfolder in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5).
  • Klartext-Passwörter in der Instanz-Config: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in /etc/pdf-ocr-hotfolder/<instanz>.toml. Deshalb chmod 640 und chown root:<service-gruppe>, und /etc/pdf-ocr-hotfolder selbst 750 root:pdfocr. Beim Debuggen nicht versehentlich in ein Ticket oder Log kopieren.

🛠️ Entwicklung

Lokaler Test ohne Installation:

cd ~/dev/gitea.sonith.de/pdf-ocr-hotfolder
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp config.example.toml /tmp/config.toml
# Pfade in /tmp/config.toml auf Test-Verzeichnisse anpassen
python -m pdf_ocr_hotfolder --config /tmp/config.toml

Tests (aus dem Repo-Root, pytest.ini setzt testpaths = tests):

pytest          # aktuell 95 Tests

ocrmypdf muss dafür nicht installiert sein: der Import in processor.py ist lazy, und tests/test_ocr_timeout.py schiebt ein Dummy-Modul in sys.modules. Die übrigen Tests mocken process_pdf bzw. arbeiten nur auf Config-Ebene.

📋 Roadmap / TODO

  • Tests (pytest) für processor und uploaders — 95 Tests
  • Test-Lücken schließen: der watchdog-Eventpfad (_Handler/Observer) wird nirgends getestet, run_verapdf() ebenso wenig (der FAIL-Pfad in process_pdf() ist getestet, die veraPDF-CLI-Anbindung selbst nicht), und run_ocr() nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch upload_nextcloud() und upload_sftp() sind ungetestet (nur upload_folder()).
  • Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
  • CLI-Subkommandos: pdf-ocr-hotfolder reprocess <error-file>
  • Optional: S3/MinIO Upload-Target
  • Docker-Image für Setups ohne systemd

🔑 Repo

📞 Kontakt

Maintainer: Dominik Höfling (Sonith GmbH)