# AI Agent Briefing — PDF OCR Hotfolder **Zuletzt aktualisiert:** 2026-09-22 **Version:** 0.5.0 **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/.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/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. ```bash journalctl -u pdf-ocr-hotfolder@ -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@.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 (seit 0.5.0 fünf statt drei): 1. Name (`[a-z0-9][a-z0-9-]*`) 2. Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/`) 3. Service-User (default `pdfocr`) 4. **OCR-Sprachen** (default `deu+eng`) — Format `^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`, bei Unsinn wird erneut gefragt. Jeder Code wird gegen `tesseract --list-langs` geprüft; fehlt einer, bietet der Installer `tesseract-ocr-` an (Unterstrich → Bindestrich, `chi_sim` → `tesseract-ocr-chi-sim`). Ablehnung oder fehlgeschlagene Installation → Warnung, dass OCR mit dieser Sprache **pro Datei** scheitert, und die Sprach-Abfrage beginnt von vorn (kein harter Abbruch). Ist `tesseract` nicht aufrufbar, wird die Prüfung übersprungen und die Eingabe unverändert übernommen. 5. **Original nach erfolgreichem OCR archivieren?** (default **nein** → `original_on_success = "delete"`). Bei ja wird der Archiv-Pfad abgefragt (Vorschlag `$BASE/archive`, absoluter Pfad Pflicht), angelegt und auf `$SVC_USER:$SVC_GROUP` gechownt — innerhalb von `$BASE` erledigt das bestehende `chown -R` das schon, nur ein Archiv **außerhalb** bekommt ein eigenes `chown -R`. - **Sprachen sind bewusst instanz-lokal**, nicht global: ein Hotfolder `buchhaltung` läuft mit `deu`, ein Hotfolder `export` mit `deu+eng+fra`. `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in `create_instance()` — jeder Durchlauf fragt neu, `deu+eng` ist nur der vorgeschlagene Default. Die Liste gehört eng gehalten: jede zusätzliche Sprache kostet Laufzeit **und** Erkennungsqualität. - 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` per `sed` generiert. Substituiert werden die vier `[paths]`-Zeilen **sowie** (seit 0.5.0) `[ocr].languages`, `[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen Kommentarzeilen über den Keys nicht getroffen werden (im Beispiel steht z.B. `"archive" : Original wird in archive_dir verschoben` als Kommentar); Pfad-Variablen laufen vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen und Archiv-Verzeichnis. - 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) — 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/.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 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 - [x] 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 ` - [ ] 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)