From b87e8498e6613b7ca516348a49d8b67a37ebdde2 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Fri, 24 Apr 2026 08:17:43 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#19:=20Fehlergrund=20bei=20fehlgeschlagen?= =?UTF-8?q?em=20KI-Aufruf=20im=20Begr=C3=BCndungsbereich=20anzeigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentCompletionEvent um optionales Feld failureMessage erweitert - DocumentProcessingCoordinator leitet Fehlermeldung bei Fehler-Status durch - GuiBatchRunResultRow um aiFailureMessage (Optional) ergänzt - GuiBatchRunCoordinator.toRow() befüllt aiFailureMessage aus dem Event - GuiBatchRunTab.buildDetailText() zeigt bei fehlendem Reasoning und vorhandenem Fehlergrund: "⚠ Fehler: " vor dem Hinweistext - Alle Tests angepasst und neue Unit-Tests für aiFailureMessage ergänzt Co-Authored-By: Claude Sonnet 4.6 --- .../gui/batchrun/GuiBatchRunCoordinator.java | 3 ++ .../in/gui/batchrun/GuiBatchRunResultRow.java | 11 ++++- .../in/gui/batchrun/GuiBatchRunTab.java | 7 ++- .../GuiBatchRunCoordinatorMiniRunTest.java | 2 +- .../batchrun/GuiBatchRunCoordinatorTest.java | 17 +++---- .../batchrun/GuiBatchRunResultRowTest.java | 40 +++++++++++++---- .../GuiBatchRunTabSelectionSmokeTest.java | 3 +- .../gui/batchrun/GuiBatchRunTabSmokeTest.java | 5 ++- .../port/in/DocumentCompletionEvent.java | 4 ++ .../DocumentProcessingCoordinator.java | 44 ++++++++++++------- .../BatchRunProgressObservationTest.java | 10 ++--- 11 files changed, 102 insertions(+), 44 deletions(-) diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java index 0b77b02..3d1c16a 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java @@ -506,6 +506,8 @@ public final class GuiBatchRunCoordinator { ? Optional.empty() : Optional.of(event.resolvedDate()); Optional reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank() ? Optional.empty() : Optional.of(event.aiReasoning()); + Optional failureMessage = event.failureMessage() == null || event.failureMessage().isBlank() + ? Optional.empty() : Optional.of(event.failureMessage()); Duration duration = event.processingDuration(); return new GuiBatchRunResultRow( event.originalFileName(), @@ -514,6 +516,7 @@ public final class GuiBatchRunCoordinator { finalName, date, reasoning, + failureMessage, duration); } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java index 9814db9..daba436 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java @@ -34,6 +34,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; * rename; empty otherwise * @param aiReasoning the AI reasoning shown in the side panel; empty when no * reasoning is available for this row + * @param aiFailureMessage eine lesbare Fehlerbeschreibung, wenn der KI-Aufruf oder die + * Verarbeitung fehlgeschlagen ist; leer bei Erfolg und + * übersprungenen Dokumenten * @param processingDuration wall-clock duration spent on the candidate in this run; * never {@code null} and never negative * @param resetPending {@code true} when the document's persistence status has been @@ -46,6 +49,7 @@ public record GuiBatchRunResultRow( Optional finalFileName, Optional resolvedDate, Optional aiReasoning, + Optional aiFailureMessage, Duration processingDuration, boolean resetPending) { @@ -79,6 +83,7 @@ public record GuiBatchRunResultRow( finalFileName = finalFileName == null ? Optional.empty() : finalFileName; resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate; aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning; + aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage; Objects.requireNonNull(processingDuration, "processingDuration must not be null"); if (processingDuration.isNegative()) { throw new IllegalArgumentException("processingDuration must not be negative"); @@ -97,6 +102,8 @@ public record GuiBatchRunResultRow( * empty) * @param aiReasoning the AI reasoning text; may be {@code null} (treated as * empty) + * @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be + * {@code null} (treated as empty) * @param processingDuration the wall-clock processing duration; never {@code null} */ public GuiBatchRunResultRow( @@ -106,9 +113,10 @@ public record GuiBatchRunResultRow( Optional finalFileName, Optional resolvedDate, Optional aiReasoning, + Optional aiFailureMessage, Duration processingDuration) { this(originalFileName, fingerprint, status, finalFileName, resolvedDate, aiReasoning, - processingDuration, false); + aiFailureMessage, processingDuration, false); } /** @@ -131,6 +139,7 @@ public record GuiBatchRunResultRow( Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Duration.ZERO, true); } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java index 474a6ac..7e6df15 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java @@ -899,7 +899,11 @@ public final class GuiBatchRunTab { builder.append('\n'); row.aiReasoning().ifPresentOrElse( reasoning -> builder.append(reasoning), - () -> builder.append(NO_REASONING_TEXT)); + () -> { + row.aiFailureMessage().ifPresent(msg -> + builder.append("\u26A0 Fehler: ").append(msg).append("\n\n")); + builder.append(NO_REASONING_TEXT); + }); return builder.toString(); } @@ -1012,6 +1016,7 @@ public final class GuiBatchRunTab { Optional.empty(), Optional.empty(), Optional.of(message), + Optional.empty(), Duration.ZERO, false); upsertResultRowByFingerprint(missingRow); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorMiniRunTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorMiniRunTest.java index b3f0c9a..dff2f6c 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorMiniRunTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorMiniRunTest.java @@ -58,7 +58,7 @@ class GuiBatchRunCoordinatorMiniRunTest { observer.onRunStarted(new RunId("mini-1"), 1); observer.onDocumentCompleted(new DocumentCompletionEvent( "a.pdf", FP1, DocumentCompletionStatus.SUCCESS, - "2026-01-01 - Test.pdf", null, null, Duration.ofMillis(50))); + "2026-01-01 - Test.pdf", null, null, null, Duration.ofMillis(50))); observer.onRunEnded(new RunSummary(1, 0, 0)); return GuiBatchRunLaunchOutcome.completed(); }; diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java index 740013d..bccedc6 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java @@ -77,10 +77,10 @@ class GuiBatchRunCoordinatorTest { observer.onRunStarted(new RunId("run-1"), 2); observer.onDocumentCompleted(new DocumentCompletionEvent( "a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, - "2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", Duration.ofMillis(20))); + "2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", null, Duration.ofMillis(20))); observer.onDocumentCompleted(new DocumentCompletionEvent( "b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT, - null, null, null, Duration.ofMillis(10))); + null, null, null, null, Duration.ofMillis(10))); observer.onRunEnded(new RunSummary(1, 1, 0)); return GuiBatchRunLaunchOutcome.completed(); }; @@ -119,7 +119,7 @@ class GuiBatchRunCoordinatorTest { observer.onRunStarted(new RunId("run-skip"), 1); observer.onDocumentCompleted(new DocumentCompletionEvent( "c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, - null, null, null, Duration.ofMillis(5))); + null, null, null, null, Duration.ofMillis(5))); observer.onRunEnded(new RunSummary(0, 0, 1)); return GuiBatchRunLaunchOutcome.completed(); }; @@ -317,7 +317,7 @@ class GuiBatchRunCoordinatorTest { private static GuiBatchRunResultRow row(DocumentCompletionStatus status) { return new GuiBatchRunResultRow( - "x.pdf", DUMMY_FP, status, null, null, null, Duration.ofMillis(1)); + "x.pdf", DUMMY_FP, status, null, null, null, null, Duration.ofMillis(1)); } private static GuiBatchRunCoordinator.Listener noOpListener() { @@ -358,7 +358,7 @@ class GuiBatchRunCoordinatorTest { BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp(); noOp.onRunStarted(new RunId("x"), 0); noOp.onDocumentCompleted(new DocumentCompletionEvent( - "a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO)); + "a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, null, Duration.ZERO)); noOp.onRunEnded(new RunSummary(0, 0, 0)); assertSame(noOp, BatchRunProgressObserver.noOp()); assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested()); @@ -370,12 +370,12 @@ class GuiBatchRunCoordinatorTest { void resultRow_rejectsInvalidInput() { try { new GuiBatchRunResultRow(" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, - null, null, null, Duration.ZERO); + null, null, null, null, Duration.ZERO); throw new AssertionError("expected IllegalArgumentException"); } catch (IllegalArgumentException expected) { /* ok */ } try { new GuiBatchRunResultRow("x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, - null, null, null, Duration.ofSeconds(-1)); + null, null, null, null, Duration.ofSeconds(-1)); throw new AssertionError("expected IllegalArgumentException"); } catch (IllegalArgumentException expected) { /* ok */ } } @@ -384,9 +384,10 @@ class GuiBatchRunCoordinatorTest { void resultRow_optionalHoldersNormaliseNullToEmpty() { GuiBatchRunResultRow row = new GuiBatchRunResultRow( "x.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT, - null, null, null, Duration.ZERO); + null, null, null, null, Duration.ZERO); assertNull(row.finalFileName().orElse(null)); assertNull(row.resolvedDate().orElse(null)); assertNull(row.aiReasoning().orElse(null)); + assertNull(row.aiFailureMessage().orElse(null)); } } diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRowTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRowTest.java index 90a91bd..5290b50 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRowTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRowTest.java @@ -35,6 +35,7 @@ class GuiBatchRunResultRowTest { Optional.of("2026-01-01 - Titel.pdf"), Optional.empty(), Optional.empty(), + Optional.empty(), Duration.ofMillis(100)); assertEquals("test.pdf", row.originalFileName()); assertEquals(FP, row.fingerprint()); @@ -46,45 +47,46 @@ class GuiBatchRunResultRowTest { void construction_nullOriginalFileName_throws() { assertThrows(NullPointerException.class, () -> new GuiBatchRunResultRow(null, FP, DocumentCompletionStatus.SUCCESS, - null, null, null, Duration.ZERO)); + null, null, null, null, Duration.ZERO)); } @Test void construction_blankOriginalFileName_throws() { assertThrows(IllegalArgumentException.class, () -> new GuiBatchRunResultRow(" ", FP, DocumentCompletionStatus.SUCCESS, - null, null, null, Duration.ZERO)); + null, null, null, null, Duration.ZERO)); } @Test void construction_nullFingerprint_throws() { assertThrows(NullPointerException.class, () -> new GuiBatchRunResultRow("test.pdf", null, DocumentCompletionStatus.SUCCESS, - null, null, null, Duration.ZERO)); + null, null, null, null, Duration.ZERO)); } @Test void construction_nullStatus_throws() { assertThrows(NullPointerException.class, () -> new GuiBatchRunResultRow("test.pdf", FP, null, - null, null, null, Duration.ZERO)); + null, null, null, null, Duration.ZERO)); } @Test void construction_negativeDuration_throws() { assertThrows(IllegalArgumentException.class, () -> new GuiBatchRunResultRow("test.pdf", FP, DocumentCompletionStatus.SUCCESS, - null, null, null, Duration.ofSeconds(-1))); + null, null, null, null, Duration.ofSeconds(-1))); } @Test void construction_nullOptionals_normalisedToEmpty() { GuiBatchRunResultRow row = new GuiBatchRunResultRow( "test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT, - null, null, null, Duration.ZERO); + null, null, null, null, Duration.ZERO); assertTrue(row.finalFileName().isEmpty()); assertTrue(row.resolvedDate().isEmpty()); assertTrue(row.aiReasoning().isEmpty()); + assertTrue(row.aiFailureMessage().isEmpty()); } // ------------------------------------------------------------------------- @@ -144,12 +146,13 @@ class GuiBatchRunResultRowTest { GuiBatchRunResultRow original = new GuiBatchRunResultRow( "test.pdf", FP, DocumentCompletionStatus.SUCCESS, Optional.of("2026-01-01 - Titel.pdf"), Optional.empty(), Optional.empty(), - Duration.ofMillis(42)); + Optional.empty(), Duration.ofMillis(42)); GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(original); assertTrue(marker.finalFileName().isEmpty()); assertTrue(marker.resolvedDate().isEmpty()); assertTrue(marker.aiReasoning().isEmpty()); + assertTrue(marker.aiFailureMessage().isEmpty()); assertEquals(Duration.ZERO, marker.processingDuration()); } @@ -173,12 +176,33 @@ class GuiBatchRunResultRowTest { } } + // ------------------------------------------------------------------------- + // aiFailureMessage + // ------------------------------------------------------------------------- + + @Test + void construction_withFailureMessage_isPresent() { + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.FAILED_RETRYABLE, + null, null, null, Optional.of("KI-Timeout"), Duration.ofMillis(10)); + assertTrue(row.aiFailureMessage().isPresent()); + assertEquals("KI-Timeout", row.aiFailureMessage().get()); + } + + @Test + void construction_withoutFailureMessage_isEmpty() { + GuiBatchRunResultRow row = new GuiBatchRunResultRow( + "test.pdf", FP, DocumentCompletionStatus.SUCCESS, + null, null, null, Optional.empty(), Duration.ofMillis(10)); + assertTrue(row.aiFailureMessage().isEmpty()); + } + // ------------------------------------------------------------------------- // Helper // ------------------------------------------------------------------------- private static GuiBatchRunResultRow row(DocumentCompletionStatus status) { return new GuiBatchRunResultRow( - "file.pdf", FP, status, null, null, null, Duration.ofMillis(1)); + "file.pdf", FP, status, null, null, null, null, Duration.ofMillis(1)); } } diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSelectionSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSelectionSmokeTest.java index f4b4e83..346b63a 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSelectionSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSelectionSmokeTest.java @@ -188,6 +188,7 @@ class GuiBatchRunTabSelectionSmokeTest { "2026-01-01 - Titel.pdf", java.time.LocalDate.of(2026, 1, 1), "reasoning", + null, Duration.ofMillis(5))); observer.onRunEnded(new RunSummary(1, 0, 0)); return GuiBatchRunLaunchOutcome.completed(); @@ -286,7 +287,7 @@ class GuiBatchRunTabSelectionSmokeTest { private static GuiBatchRunResultRow row(String name, DocumentFingerprint fp, DocumentCompletionStatus status) { return new GuiBatchRunResultRow(name, fp, status, - Optional.empty(), Optional.empty(), Optional.empty(), Duration.ofMillis(1)); + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Duration.ofMillis(1)); } private void runOnFx(Runnable action) throws InterruptedException { diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java index 6003a10..06d7eb5 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java @@ -101,13 +101,14 @@ class GuiBatchRunTabSmokeTest { "2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut begründet", + null, Duration.ofMillis(42))); observer.onDocumentCompleted(new DocumentCompletionEvent( "b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_RETRYABLE, - null, null, null, Duration.ofMillis(10))); + null, null, null, null, Duration.ofMillis(10))); observer.onDocumentCompleted(new DocumentCompletionEvent( "c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, - null, null, null, Duration.ofMillis(5))); + null, null, null, null, Duration.ofMillis(5))); observer.onRunEnded(new RunSummary(1, 1, 1)); return GuiBatchRunLaunchOutcome.completed(); }; diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java index 731b5b6..c53c520 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java @@ -31,6 +31,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; * any is available for this candidate (may be present on success * and on some failure paths where an AI call had previously * produced a reasoning); {@code null} when no reasoning exists + * @param failureMessage a human-readable description of the failure when the status + * indicates an error; {@code null} for successful or skipped + * candidates * @param processingDuration the wall-clock duration spent on this candidate in the current * run; never {@code null} and never negative */ @@ -41,6 +44,7 @@ public record DocumentCompletionEvent( String finalFileName, LocalDate resolvedDate, String aiReasoning, + String failureMessage, Duration processingDuration) { /** diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index 1d708f2..ec1c768 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -618,6 +618,7 @@ public class DocumentProcessingCoordinator { resolvedFilename, proposalAttempt.resolvedDate(), proposalAttempt.aiReasoning(), + null, attemptStart, now); return true; @@ -701,7 +702,7 @@ public class DocumentProcessingCoordinator { publishCompletion(candidate, fingerprint, retryable ? DocumentCompletionStatus.FAILED_RETRYABLE : DocumentCompletionStatus.FAILED_PERMANENT, - null, null, null, attemptStart, now); + null, null, null, errorMessage, attemptStart, now); return true; } catch (DocumentPersistenceException persistEx) { @@ -771,7 +772,7 @@ public class DocumentProcessingCoordinator { transition.retryable() ? DocumentCompletionStatus.FAILED_RETRYABLE : DocumentCompletionStatus.FAILED_PERMANENT, - null, null, reasoning, attemptStart, now); + null, null, reasoning, errorMessage, attemptStart, now); if (!secondaryPersisted) { logger.debug("Completion for '{}' reported without secondary persistence record.", @@ -815,7 +816,7 @@ public class DocumentProcessingCoordinator { logger.debug("Skip attempt #{} persisted for '{}' with status {}.", attemptNumber, candidate.uniqueIdentifier(), skipStatus); publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED, - null, null, null, attemptStart, now); + null, null, null, null, attemptStart, now); return true; } catch (DocumentPersistenceException e) { @@ -1080,12 +1081,17 @@ public class DocumentProcessingCoordinator { outcome.counters().contentErrorCount(), outcome.counters().transientErrorCount()); } - // Pipeline-path terminal resolutions are reported to the progress observer. - // PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes - // the actual completion event (SUCCESS or transient-error failure). + // Pipeline-Pfad: terminale Auflösungen werden dem Progress-Observer gemeldet. + // PROPOSAL_READY ist ein Zwischenstatus; die Finalisierung publiziert das eigentliche + // Completion-Event (SUCCESS oder Fehler). if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) { + String pipelineFailureMessage = null; + if (outcome.overallStatus() == ProcessingStatus.FAILED_RETRYABLE + || outcome.overallStatus() == ProcessingStatus.FAILED_FINAL) { + pipelineFailureMessage = buildFailureMessage(pipelineOutcome, outcome); + } publishCompletion(candidate, fingerprint, toCompletionStatus(outcome), - null, null, null, attemptStart, now); + null, null, null, pipelineFailureMessage, attemptStart, now); } return true; @@ -1216,15 +1222,17 @@ public class DocumentProcessingCoordinator { * Any runtime exception thrown by the observer is caught and logged at warn level and must * not affect persistence or batch flow. * - * @param candidate the candidate being reported; must not be null - * @param fingerprint the content-based identity of the document; must not be null - * @param status the aggregated completion status; must not be null - * @param finalFileName the final target filename on success; {@code null} otherwise - * @param resolvedDate the resolved date on success; may be {@code null} otherwise - * @param aiReasoning the AI reasoning when one is available for this result; - * {@code null} otherwise - * @param startInstant the moment processing of the candidate began in this run - * @param endInstant the moment the terminal resolution was reached + * @param candidate die Kandidatdatei, über die berichtet wird; darf nicht null sein + * @param fingerprint der inhaltsbasierte Fingerprint des Dokuments; darf nicht null sein + * @param status der aggregierte Abschlussstatus; darf nicht null sein + * @param finalFileName der finale Zieldateiname bei Erfolg; sonst {@code null} + * @param resolvedDate das aufgelöste Datum bei Erfolg; bei Fehlern kann es {@code null} sein + * @param aiReasoning das KI-Reasoning, wenn für dieses Ergebnis eines vorliegt; + * sonst {@code null} + * @param failureMessage eine lesbare Fehlerbeschreibung bei Fehler-Status; bei Erfolg + * und Überspringen {@code null} + * @param startInstant der Zeitpunkt, zu dem die Verarbeitung des Kandidaten begann + * @param endInstant der Zeitpunkt der terminalen Auflösung */ private void publishCompletion( SourceDocumentCandidate candidate, @@ -1233,6 +1241,7 @@ public class DocumentProcessingCoordinator { String finalFileName, LocalDate resolvedDate, String aiReasoning, + String failureMessage, Instant startInstant, Instant endInstant) { Consumer forwarder = completionForwarder; @@ -1251,9 +1260,10 @@ public class DocumentProcessingCoordinator { finalFileName, resolvedDate, aiReasoning, + failureMessage, duration)); } catch (RuntimeException forwarderFailure) { - logger.warn("Progress forwarder threw while reporting completion for '{}': {}", + logger.warn("Progress forwarder beim Melden des Abschlusses für '{}' aufgetreten: {}", candidate.uniqueIdentifier(), forwarderFailure.getMessage(), forwarderFailure); } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java index 9640758..f40717d 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java @@ -85,13 +85,13 @@ class BatchRunProgressObservationTest { @Test void documentCompletionEvent_rejectsBlankFilename() { assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent( - " ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO)); + " ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, null, Duration.ZERO)); } @Test void documentCompletionEvent_rejectsNegativeDuration() { assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent( - "x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, + "x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, null, Duration.ofSeconds(-1))); } @@ -99,7 +99,7 @@ class BatchRunProgressObservationTest { void documentCompletionEvent_carriesOptionalFields() { DocumentCompletionEvent event = new DocumentCompletionEvent( "x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf", - LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123)); + LocalDate.of(2026, 3, 1), "weil wichtig", null, Duration.ofMillis(123)); assertEquals("x.pdf", event.originalFileName()); assertEquals(DocumentCompletionStatus.SUCCESS, event.status()); @@ -129,7 +129,7 @@ class BatchRunProgressObservationTest { assertSame(a, b); a.onRunStarted(new RunId("r-1"), 5); a.onDocumentCompleted(new DocumentCompletionEvent( - "x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO)); + "x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, null, null, null, null, Duration.ZERO)); a.onRunEnded(new RunSummary(0, 0, 0)); } @@ -438,7 +438,7 @@ class BatchRunProgressObservationTest { candidate.uniqueIdentifier(), fingerprint, statuses.get(index), - null, null, null, Duration.ofMillis(10))); + null, null, null, null, Duration.ofMillis(10))); } onBeforeReturn.run(); return true;