- 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>
14 KiB
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 |
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
pdfocran (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) mitUser=/Group=Override - Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via
id -gnermittelt - 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>.tomlwird ausconfig.example.tomlmit sed-substituierten Pfaden generiert- Instanz wird sofort
enable --nowgestartet
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:
- Findet das Repo (eigenes Verzeichnis oder
/opt/pdf-ocr-hotfolder/.repo_path) - Ermittelt alle aktiven
pdf-ocr-hotfolder@*.serviceUnits und stoppt sie - Backup nach
/var/backups/pdf-ocr-hotfolder/(tar.gz, ohne venv/__pycache__) - Kopiert Code + requirements + VERSION + config.example aus dem Repo
pip install --upgradeim venv- Aktualisiert Template-Unit +
daemon-reload - Setzt den Code-Eigentümer auf den User, dem
venvgehört (defaultpdfocr) - 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:
check_preflight()—tesseractundgsmüssen im PATH sein; istpdfa_levelgesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüftcheck_output_config()— validiertoriginal_on_success,archive_dir(Pflicht beiarchive) undname_mode- Scheitert eines davon →
PreflightError, CLI beendet sich mit Exit-Code 2 (ebenso bei kaputter/fehlender Config)
Pro Datei:
watchdogtriggert aufcreated/moved/closedinincoming/(beim Start greift_scan_existing()bereits liegende PDFs auf)_wait_until_stable()wartet, bis die Datei nicht mehr wächst (max. ~60s)- Move nach
working/ ocrmypdf.ocr()als Library-Call (kein Subprozess-Start pro PDF)- Optional: veraPDF-Validierung (CLI-Subprozess) — bei FAIL geht das OCR-Ergebnis nach
error/, das Original folgtoriginal_on_success(wird also beiarchivenicht gelöscht) - Move nach
outgoing/unter dem laut[output]gebauten Namen (build_output_name():prefix/suffix/none+name_tag— das harteOCR_-Präfix aus 0.1.0 ist nur noch der Default) - Original in
working/wird lautoriginal_on_successgelöscht oder nacharchive_dirarchiviert (Kollision → Timestamp-Suffix) - Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
- 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 --jobsan ocrmypdf: Tesseract parallelisiert Seiten innerhalb eines PDFsskip_text=True: bereits OCR-haltige Seiten werden nicht neu verarbeitet- Stabilitäts-Check statt magic-file
new(alte Bash-Krücke) upload_folder()nutztshutil.copyfile()stattread_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 = trueblockiert ocrmypdf komplett (Issue #3). Deshalb istpdfa_level = ""der sichere Default, und der Preflight bricht mit Exit 2 ab, wennpdfa_levelgesetzt und die GS-Version betroffen ist. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an). [ocr].timeoutist ein Timeout PRO SEITE, kein Gesamt-Timeout pro PDF. Der Wert geht alstesseract_timeout(ocrmypdf-Option--tesseract-timeout) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default1800in einer Config stehen hat, gibt Tesseract 30 Minuten je Seite — Richtwert ist 300. Ein durchgereichtes0würde ocrmypdf dazu bringen, OCR still zu überspringen, deshalb wird bei0(oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.- systemd-Hardening bricht in LXC-Containern (
Error 226/NAMESPACEdurchPrivateTmp,ProtectSystemusw., Issue #4). Gegenmittel ist das Drop-insystemd/lxc-compat.confnach/etc/systemd/system/pdf-ocr-hotfolder@.service.d/; der Installer erkennt Container viasystemd-detect-virt --containerund bietet es an. - Das Paket wird nicht pip-installiert, sondern nach
/opt/pdf-ocr-hotfolderkopiert. Gestartet wird perpython -m pdf_ocr_hotfolder, gefunden wird das Modul nur über das Arbeitsverzeichnis —WorkingDirectory=/opt/pdf-ocr-hotfolderin 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. Deshalbchmod 640undchown root:<service-gruppe>, und/etc/pdf-ocr-hotfolderselbst750 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ürprocessorunduploaders— 95 Tests - Test-Lücken schließen: der watchdog-Eventpfad (
_Handler/Observer) wird nirgends getestet,run_verapdf()ebenso wenig (der FAIL-Pfad inprocess_pdf()ist getestet, die veraPDF-CLI-Anbindung selbst nicht), undrun_ocr()nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auchupload_nextcloud()undupload_sftp()sind ungetestet (nurupload_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
- Repo: https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
- Owner: sonith_ug
- Versionierung: Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell)
- Tags:
v{VERSION}, automatischer Push nach Commit
📞 Kontakt
Maintainer: Dominik Höfling (Sonith GmbH)