From 8da0b7da1c7532d765d1f5ace4b3f704469f10f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20H=C3=B6fling?= Date: Tue, 22 Sep 2026 21:10:27 +0200 Subject: [PATCH] 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) --- AI_AGENT_BRIEFING.md | 28 +++++++++----- CHANGELOG.md | 27 +++++++++++++ README.md | 13 ++++++- VERSION | 2 +- install.sh | 5 +-- pdf_ocr_hotfolder/__init__.py | 2 +- pdf_ocr_hotfolder/processor.py | 17 ++++++++- tests/test_output_naming.py | 69 ++++++++++++++++++++++++++++++++++ 8 files changed, 144 insertions(+), 19 deletions(-) diff --git a/AI_AGENT_BRIEFING.md b/AI_AGENT_BRIEFING.md index 56cb8e6..15b02fd 100644 --- a/AI_AGENT_BRIEFING.md +++ b/AI_AGENT_BRIEFING.md @@ -1,8 +1,8 @@ # 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. +**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 @@ -19,7 +19,7 @@ pdf-ocr-hotfolder/ │ ├── 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) +├── tests/ # pytest-Suite (95 Tests, ocrmypdf wird gemockt) │ ├── conftest.py # Fixtures tmp_config / dummy_pdf │ ├── test_config_errors.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/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 | +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) @@ -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) 3. Move nach `working/` 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) 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):** +**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 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/` | | 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`): ```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. ## 📋 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()`). +- [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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d63a6c3..efd308e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 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@ -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 ### Added diff --git a/README.md b/README.md index 9271917..5b89ac0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut s | `/var/lib/pdf-ocr-hotfolder//working` | Arbeitsverzeichnis während OCR | | `/var/lib/pdf-ocr-hotfolder//outgoing` | Ausgang (fertige PDFs) | | `/var/lib/pdf-ocr-hotfolder//error` | Fehlgeschlagene PDFs | -| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | ## Konfiguration @@ -148,6 +147,16 @@ sudo systemctl status '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@ -f # eine Instanz mitlesen +journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute +``` + ## Update ```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 diff --git a/VERSION b/VERSION index 1d0ba9e..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/install.sh b/install.sh index e85ab07..5f2cc3b 100755 --- a/install.sh +++ b/install.sh @@ -26,7 +26,6 @@ fi INSTALL_DIR="/opt/pdf-ocr-hotfolder" CONFIG_DIR="/etc/pdf-ocr-hotfolder" DATA_ROOT="/var/lib/pdf-ocr-hotfolder" -LOG_DIR="/var/log/pdf-ocr-hotfolder" SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" DEFAULT_USER="pdfocr" @@ -115,7 +114,7 @@ install_base() { fi 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" chmod 750 "$CONFIG_DIR" @@ -140,7 +139,7 @@ install_base() { systemctl daemon-reload log_info "Template-Unit installiert ✓" - chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" "$LOG_DIR" + chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" } # ============================================================ diff --git a/pdf_ocr_hotfolder/__init__.py b/pdf_ocr_hotfolder/__init__.py index b727522..13c2e91 100644 --- a/pdf_ocr_hotfolder/__init__.py +++ b/pdf_ocr_hotfolder/__init__.py @@ -1,3 +1,3 @@ """PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen.""" -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/pdf_ocr_hotfolder/processor.py b/pdf_ocr_hotfolder/processor.py index 7182b2d..bc41430 100644 --- a/pdf_ocr_hotfolder/processor.py +++ b/pdf_ocr_hotfolder/processor.py @@ -133,8 +133,17 @@ def process_pdf( if vera_cfg.enabled: vera_ok = run_verapdf(work_out, vera_cfg) 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) - 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, "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: - """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(): return mode = cfg.original_on_success diff --git a/tests/test_output_naming.py b/tests/test_output_naming.py index ffd6e9f..a8fab4f 100644 --- a/tests/test_output_naming.py +++ b/tests/test_output_naming.py @@ -244,3 +244,72 @@ def test_process_pdf_archive_name_collision(tmp_path: Path) -> None: archived = list(env["archive"].glob("scan_*.pdf")) assert len(archived) == 1 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"