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>
This commit is contained in:
2026-09-22 21:10:27 +02:00
parent 578472872e
commit 8da0b7da1c
8 changed files with 144 additions and 19 deletions
+18 -10
View File
@@ -1,8 +1,8 @@
# AI Agent Briefing — PDF OCR Hotfolder # AI Agent Briefing — PDF OCR Hotfolder
**Zuletzt aktualisiert:** 2026-09-22 **Zuletzt aktualisiert:** 2026-09-22
**Version:** 0.4.0 **Version:** 0.4.1
**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. **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 ## 🎯 Projektziel
@@ -19,7 +19,7 @@ pdf-ocr-hotfolder/
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Zähler │ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Zähler
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung │ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify │ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
├── tests/ # pytest-Suite (92 Tests, ocrmypdf wird gemockt) ├── tests/ # pytest-Suite (95 Tests, ocrmypdf wird gemockt)
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf │ ├── conftest.py # Fixtures tmp_config / dummy_pdf
│ ├── test_config_errors.py │ ├── test_config_errors.py
│ ├── test_error_counting.py │ ├── test_error_counting.py
@@ -67,9 +67,17 @@ pdf-ocr-hotfolder/
| `/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/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) | | `/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/lib/pdf-ocr-hotfolder/<instanz>/{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 | | `/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@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## 👤 Service-User ## 👤 Service-User
- Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden) - Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden)
@@ -142,20 +150,20 @@ Unbekannte Keys in einer Sektion werden beim Laden **still verworfen** (`config.
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s) 2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
3. Move nach `working/` 3. Move nach `working/`
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF) 4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF)
5. Optional: veraPDF-Validierung (CLI-Subprozess) 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) 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) 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) 8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
9. E-Mail-Notify je nach `[notify.email].on` 9. E-Mail-Notify je nach `[notify.email].on`
**Fehlerbehandlung (Stand 0.4.0):** **Fehlerbehandlung (Stand 0.4.1):**
| Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach | | 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 | | 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 | — | | Datei verschwindet vor der Verarbeitung | nein | — |
| OCR wirft (ocrmypdf) | ja | `error/` | | OCR wirft (ocrmypdf) | ja | `error/` |
| veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original wird gelöscht | | 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/` | | 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 | | Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele |
@@ -193,15 +201,15 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
```bash ```bash
pytest # aktuell 92 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. `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 ## 📋 Roadmap / TODO
- [x] Tests (`pytest`) für `processor` und `uploaders` — 92 Tests - [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, 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()`). - [ ] 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) - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>` - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
- [ ] Optional: S3/MinIO Upload-Target - [ ] Optional: S3/MinIO Upload-Target
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## [0.4.1] - 2026-09-22
### Fixed
- **veraPDF-FAIL hat das Original immer gelöscht.** Schlug die PDF/A-Validierung
fehl, wanderte das OCR-Ergebnis nach `error/` und das Original wurde per
`unlink()` entfernt — unabhängig von `[output].original_on_success`. Wer
`archive` konfiguriert hatte, verlor die Datei also ausgerechnet im
Fehlerfall. Der FAIL-Pfad nutzt jetzt dieselbe `_dispose_original()`-Logik
wie der Erfolgsfall: `archive` legt das Original samt
Timestamp-Kollisionsschutz im `archive_dir` ab, `delete` verhält sich wie
bisher. Die Log-Meldung nennt jetzt beides — wohin das OCR-Ergebnis ging und
was mit dem Original passiert ist.
### Removed
- Das nie benutzte Logverzeichnis `/var/log/pdf-ocr-hotfolder/` wird nicht mehr
vom Installer angelegt und ist aus README und Briefing entfernt. Es hat nie
ein Logfile enthalten: `_setup_logging()` nutzt `logging.basicConfig()` ohne
FileHandler, der Dienst loggt nach stdout → journald. **journald ist damit die
einzige Log-Quelle** (`journalctl -u pdf-ocr-hotfolder@<instanz> -f`).
Weder Installer noch Updater fassen das Verzeichnis an: ein vorhandenes,
leeres `/var/log/pdf-ocr-hotfolder/` kann auf bestehenden Installationen
gefahrlos von Hand entfernt werden (`sudo rmdir /var/log/pdf-ocr-hotfolder`).
### Added
- 3 neue Tests für den veraPDF-FAIL-Pfad (`delete`, `archive`,
Archiv-Namenskollision); veraPDF wird dabei gemockt. Suite jetzt 95 Tests.
## [0.4.0] - 2026-09-22 ## [0.4.0] - 2026-09-22
### Added ### Added
+11 -2
View File
@@ -71,7 +71,6 @@ Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut s
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR | | `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) | | `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs | | `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs |
| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
## Konfiguration ## Konfiguration
@@ -148,6 +147,16 @@ sudo systemctl status 'pdf-ocr-hotfolder@*'
sudo systemctl restart 'pdf-ocr-hotfolder@*' sudo systemctl restart 'pdf-ocr-hotfolder@*'
``` ```
### Logs
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
ins journal:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## Update ## Update
```bash ```bash
@@ -239,5 +248,5 @@ MIT — © Sonith UG
--- ---
**Version:** 0.4.0 **Version:** 0.4.1
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.4.0 0.4.1
+2 -3
View File
@@ -26,7 +26,6 @@ fi
INSTALL_DIR="/opt/pdf-ocr-hotfolder" INSTALL_DIR="/opt/pdf-ocr-hotfolder"
CONFIG_DIR="/etc/pdf-ocr-hotfolder" CONFIG_DIR="/etc/pdf-ocr-hotfolder"
DATA_ROOT="/var/lib/pdf-ocr-hotfolder" DATA_ROOT="/var/lib/pdf-ocr-hotfolder"
LOG_DIR="/var/log/pdf-ocr-hotfolder"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
DEFAULT_USER="pdfocr" DEFAULT_USER="pdfocr"
@@ -115,7 +114,7 @@ install_base() {
fi fi
log_step "Verzeichnisse anlegen" log_step "Verzeichnisse anlegen"
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT" "$LOG_DIR" mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT"
chown root:"$DEFAULT_USER" "$CONFIG_DIR" chown root:"$DEFAULT_USER" "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR" chmod 750 "$CONFIG_DIR"
@@ -140,7 +139,7 @@ install_base() {
systemctl daemon-reload systemctl daemon-reload
log_info "Template-Unit installiert ✓" log_info "Template-Unit installiert ✓"
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" "$LOG_DIR" chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR"
} }
# ============================================================ # ============================================================
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen.""" """PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.4.0" __version__ = "0.4.1"
+15 -2
View File
@@ -133,8 +133,17 @@ def process_pdf(
if vera_cfg.enabled: if vera_cfg.enabled:
vera_ok = run_verapdf(work_out, vera_cfg) vera_ok = run_verapdf(work_out, vera_cfg)
if not vera_ok: if not vera_ok:
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
# [output].original_on_success-Regel wie im Erfolgsfall, sonst
# verliert man es ausgerechnet im Fehlerfall (archive!).
_move_to_error(work_out, error_dir) _move_to_error(work_out, error_dir)
work_src.unlink(missing_ok=True) _dispose_original(work_src, src.name, output_cfg)
log.error(
"veraPDF FAIL: %s — OCR-Ergebnis nach %s verschoben, Original %s",
src.name, error_dir,
"archiviert" if output_cfg.original_on_success == "archive" else "gelöscht",
)
return ProcessResult(src, final_out, False, return ProcessResult(src, final_out, False,
"verapdf validation failed", verapdf_passed=False) "verapdf validation failed", verapdf_passed=False)
@@ -145,7 +154,11 @@ def process_pdf(
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None: def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original nach erfolgreichem OCR — löschen oder archivieren.""" """Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
"""
if not work_src.exists(): if not work_src.exists():
return return
mode = cfg.original_on_success mode = cfg.original_on_success
+69
View File
@@ -244,3 +244,72 @@ def test_process_pdf_archive_name_collision(tmp_path: Path) -> None:
archived = list(env["archive"].glob("scan_*.pdf")) archived = list(env["archive"].glob("scan_*.pdf"))
assert len(archived) == 1 assert len(archived) == 1
assert archived[0].read_bytes() == b"%PDF-1.4 original\n" assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
# ---------------- veraPDF FAIL: Original folgt original_on_success ----------------
def _run_with_vera_fail(env: dict, out_cfg: OutputConfig):
"""process_pdf mit gemocktem OCR und einem veraPDF, das FAIL meldet."""
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
patch("pdf_ocr_hotfolder.processor.run_verapdf", return_value=False):
return process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=True),
output_cfg=out_cfg,
)
def test_process_pdf_verapdf_fail_delete_removes_original(tmp_path: Path) -> None:
"""delete: Verhalten wie bisher — OCR-Ergebnis nach error/, Original weg."""
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete")
result = _run_with_vera_fail(env, out_cfg)
assert not result.success
assert result.verapdf_passed is False
# OCR-Ergebnis liegt in error/
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
# Original ist weg
assert not env["src"].exists()
assert not (env["working"] / "scan.pdf").exists()
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
def test_process_pdf_verapdf_fail_archive_keeps_original(tmp_path: Path) -> None:
"""archive: das Original darf im Fehlerfall NICHT verloren gehen."""
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
result = _run_with_vera_fail(env, out_cfg)
assert not result.success
assert result.verapdf_passed is False
# OCR-Ergebnis liegt in error/
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
# Original liegt unversehrt im Archiv
archived = env["archive"] / "scan.pdf"
assert archived.exists()
assert archived.read_bytes() == b"%PDF-1.4 original\n"
assert not (env["working"] / "scan.pdf").exists()
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
def test_process_pdf_verapdf_fail_archive_name_collision(tmp_path: Path) -> None:
"""Auch im veraPDF-FAIL-Pfad greift der Timestamp-Kollisionsschutz."""
env = _prepare(tmp_path)
(env["archive"] / "scan.pdf").write_bytes(b"old")
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
_run_with_vera_fail(env, out_cfg)
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
archived = list(env["archive"].glob("scan_*.pdf"))
assert len(archived) == 1
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"