# AI Agent Briefing — PDF OCR Hotfolder **Zuletzt aktualisiert:** 2026-09-22 **Version:** 0.4.0 **Status:** Multi-Instanz-Betrieb, Preflight-Checks und Fehlerzählung vorhanden, Test-Suite grün (92 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 (92 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/.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@.service.d/user.conf` | Drop-in für abweichenden User (optional) | | `/var/lib/pdf-ocr-hotfolder//{incoming,working,outgoing,error}/` | Daten pro Instanz | | `/var/log/pdf-ocr-hotfolder/` | vom Installer angelegt; der Service selbst loggt nach stdout → journald | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | ## 👤 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@.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/`), 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 - `.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert - Instanz wird sofort `enable --now` gestartet Manuelles Löschen einer Instanz: ```bash systemctl disable --now pdf-ocr-hotfolder@ rm /etc/pdf-ocr-hotfolder/.toml rm -rf /etc/systemd/system/pdf-ocr-hotfolder@.service.d systemctl daemon-reload # Datenverzeichnis /var/lib/pdf-ocr-hotfolder/ 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) 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.0):** | 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 wird gelöscht | | 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/.toml`. Deshalb `chmod 640` und `chown root:`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. Beim Debuggen nicht versehentlich in ein Ticket oder Log kopieren. ## 🛠️ Entwicklung Lokaler Test ohne Installation: ```bash 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`): ```bash pytest # aktuell 92 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 - [x] Tests (`pytest`) für `processor` und `uploaders` — 92 Tests - [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig, 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 ` - [ ] 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)