Fix #19: Fehlergrund bei fehlgeschlagenem KI-Aufruf im Begründungsbereich anzeigen

- DocumentCompletionEvent um optionales Feld failureMessage erweitert
- DocumentProcessingCoordinator leitet Fehlermeldung bei Fehler-Status durch
- GuiBatchRunResultRow um aiFailureMessage (Optional<String>) ergänzt
- GuiBatchRunCoordinator.toRow() befüllt aiFailureMessage aus dem Event
- GuiBatchRunTab.buildDetailText() zeigt bei fehlendem Reasoning und
  vorhandenem Fehlergrund: "⚠ Fehler: <Meldung>" vor dem Hinweistext
- Alle Tests angepasst und neue Unit-Tests für aiFailureMessage ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 08:17:43 +02:00
parent 67275eb2f5
commit b87e8498e6
11 changed files with 102 additions and 44 deletions
@@ -506,6 +506,8 @@ public final class GuiBatchRunCoordinator {
? Optional.empty() : Optional.of(event.resolvedDate()); ? Optional.empty() : Optional.of(event.resolvedDate());
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank() Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
? Optional.empty() : Optional.of(event.aiReasoning()); ? Optional.empty() : Optional.of(event.aiReasoning());
Optional<String> failureMessage = event.failureMessage() == null || event.failureMessage().isBlank()
? Optional.empty() : Optional.of(event.failureMessage());
Duration duration = event.processingDuration(); Duration duration = event.processingDuration();
return new GuiBatchRunResultRow( return new GuiBatchRunResultRow(
event.originalFileName(), event.originalFileName(),
@@ -514,6 +516,7 @@ public final class GuiBatchRunCoordinator {
finalName, finalName,
date, date,
reasoning, reasoning,
failureMessage,
duration); duration);
} }
@@ -34,6 +34,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* rename; empty otherwise * rename; empty otherwise
* @param aiReasoning the AI reasoning shown in the side panel; empty when no * @param aiReasoning the AI reasoning shown in the side panel; empty when no
* reasoning is available for this row * 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; * @param processingDuration wall-clock duration spent on the candidate in this run;
* never {@code null} and never negative * never {@code null} and never negative
* @param resetPending {@code true} when the document's persistence status has been * @param resetPending {@code true} when the document's persistence status has been
@@ -46,6 +49,7 @@ public record GuiBatchRunResultRow(
Optional<String> finalFileName, Optional<String> finalFileName,
Optional<LocalDate> resolvedDate, Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning, Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration, Duration processingDuration,
boolean resetPending) { boolean resetPending) {
@@ -79,6 +83,7 @@ public record GuiBatchRunResultRow(
finalFileName = finalFileName == null ? Optional.empty() : finalFileName; finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate; resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning; aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
Objects.requireNonNull(processingDuration, "processingDuration must not be null"); Objects.requireNonNull(processingDuration, "processingDuration must not be null");
if (processingDuration.isNegative()) { if (processingDuration.isNegative()) {
throw new IllegalArgumentException("processingDuration must not be negative"); throw new IllegalArgumentException("processingDuration must not be negative");
@@ -97,6 +102,8 @@ public record GuiBatchRunResultRow(
* empty) * empty)
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as * @param aiReasoning the AI reasoning text; may be {@code null} (treated as
* empty) * 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} * @param processingDuration the wall-clock processing duration; never {@code null}
*/ */
public GuiBatchRunResultRow( public GuiBatchRunResultRow(
@@ -106,9 +113,10 @@ public record GuiBatchRunResultRow(
Optional<String> finalFileName, Optional<String> finalFileName,
Optional<LocalDate> resolvedDate, Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning, Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration) { Duration processingDuration) {
this(originalFileName, fingerprint, status, finalFileName, resolvedDate, aiReasoning, 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(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Duration.ZERO, Duration.ZERO,
true); true);
} }
@@ -899,7 +899,11 @@ public final class GuiBatchRunTab {
builder.append('\n'); builder.append('\n');
row.aiReasoning().ifPresentOrElse( row.aiReasoning().ifPresentOrElse(
reasoning -> builder.append(reasoning), 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(); return builder.toString();
} }
@@ -1012,6 +1016,7 @@ public final class GuiBatchRunTab {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.of(message), Optional.of(message),
Optional.empty(),
Duration.ZERO, Duration.ZERO,
false); false);
upsertResultRowByFingerprint(missingRow); upsertResultRowByFingerprint(missingRow);
@@ -58,7 +58,7 @@ class GuiBatchRunCoordinatorMiniRunTest {
observer.onRunStarted(new RunId("mini-1"), 1); observer.onRunStarted(new RunId("mini-1"), 1);
observer.onDocumentCompleted(new DocumentCompletionEvent( observer.onDocumentCompleted(new DocumentCompletionEvent(
"a.pdf", FP1, DocumentCompletionStatus.SUCCESS, "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)); observer.onRunEnded(new RunSummary(1, 0, 0));
return GuiBatchRunLaunchOutcome.completed(); return GuiBatchRunLaunchOutcome.completed();
}; };
@@ -77,10 +77,10 @@ class GuiBatchRunCoordinatorTest {
observer.onRunStarted(new RunId("run-1"), 2); observer.onRunStarted(new RunId("run-1"), 2);
observer.onDocumentCompleted(new DocumentCompletionEvent( observer.onDocumentCompleted(new DocumentCompletionEvent(
"a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, "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( observer.onDocumentCompleted(new DocumentCompletionEvent(
"b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT, "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)); observer.onRunEnded(new RunSummary(1, 1, 0));
return GuiBatchRunLaunchOutcome.completed(); return GuiBatchRunLaunchOutcome.completed();
}; };
@@ -119,7 +119,7 @@ class GuiBatchRunCoordinatorTest {
observer.onRunStarted(new RunId("run-skip"), 1); observer.onRunStarted(new RunId("run-skip"), 1);
observer.onDocumentCompleted(new DocumentCompletionEvent( observer.onDocumentCompleted(new DocumentCompletionEvent(
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, "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)); observer.onRunEnded(new RunSummary(0, 0, 1));
return GuiBatchRunLaunchOutcome.completed(); return GuiBatchRunLaunchOutcome.completed();
}; };
@@ -317,7 +317,7 @@ class GuiBatchRunCoordinatorTest {
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) { private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
return new GuiBatchRunResultRow( 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() { private static GuiBatchRunCoordinator.Listener noOpListener() {
@@ -358,7 +358,7 @@ class GuiBatchRunCoordinatorTest {
BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp(); BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp();
noOp.onRunStarted(new RunId("x"), 0); noOp.onRunStarted(new RunId("x"), 0);
noOp.onDocumentCompleted(new DocumentCompletionEvent( 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)); noOp.onRunEnded(new RunSummary(0, 0, 0));
assertSame(noOp, BatchRunProgressObserver.noOp()); assertSame(noOp, BatchRunProgressObserver.noOp());
assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested()); assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested());
@@ -370,12 +370,12 @@ class GuiBatchRunCoordinatorTest {
void resultRow_rejectsInvalidInput() { void resultRow_rejectsInvalidInput() {
try { try {
new GuiBatchRunResultRow(" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, new GuiBatchRunResultRow(" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ZERO); null, null, null, null, Duration.ZERO);
throw new AssertionError("expected IllegalArgumentException"); throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) { /* ok */ } } catch (IllegalArgumentException expected) { /* ok */ }
try { try {
new GuiBatchRunResultRow("x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, 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"); throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) { /* ok */ } } catch (IllegalArgumentException expected) { /* ok */ }
} }
@@ -384,9 +384,10 @@ class GuiBatchRunCoordinatorTest {
void resultRow_optionalHoldersNormaliseNullToEmpty() { void resultRow_optionalHoldersNormaliseNullToEmpty() {
GuiBatchRunResultRow row = new GuiBatchRunResultRow( GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"x.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT, "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.finalFileName().orElse(null));
assertNull(row.resolvedDate().orElse(null)); assertNull(row.resolvedDate().orElse(null));
assertNull(row.aiReasoning().orElse(null)); assertNull(row.aiReasoning().orElse(null));
assertNull(row.aiFailureMessage().orElse(null));
} }
} }
@@ -35,6 +35,7 @@ class GuiBatchRunResultRowTest {
Optional.of("2026-01-01 - Titel.pdf"), Optional.of("2026-01-01 - Titel.pdf"),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Duration.ofMillis(100)); Duration.ofMillis(100));
assertEquals("test.pdf", row.originalFileName()); assertEquals("test.pdf", row.originalFileName());
assertEquals(FP, row.fingerprint()); assertEquals(FP, row.fingerprint());
@@ -46,45 +47,46 @@ class GuiBatchRunResultRowTest {
void construction_nullOriginalFileName_throws() { void construction_nullOriginalFileName_throws() {
assertThrows(NullPointerException.class, () -> assertThrows(NullPointerException.class, () ->
new GuiBatchRunResultRow(null, FP, DocumentCompletionStatus.SUCCESS, new GuiBatchRunResultRow(null, FP, DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ZERO)); null, null, null, null, Duration.ZERO));
} }
@Test @Test
void construction_blankOriginalFileName_throws() { void construction_blankOriginalFileName_throws() {
assertThrows(IllegalArgumentException.class, () -> assertThrows(IllegalArgumentException.class, () ->
new GuiBatchRunResultRow(" ", FP, DocumentCompletionStatus.SUCCESS, new GuiBatchRunResultRow(" ", FP, DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ZERO)); null, null, null, null, Duration.ZERO));
} }
@Test @Test
void construction_nullFingerprint_throws() { void construction_nullFingerprint_throws() {
assertThrows(NullPointerException.class, () -> assertThrows(NullPointerException.class, () ->
new GuiBatchRunResultRow("test.pdf", null, DocumentCompletionStatus.SUCCESS, new GuiBatchRunResultRow("test.pdf", null, DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ZERO)); null, null, null, null, Duration.ZERO));
} }
@Test @Test
void construction_nullStatus_throws() { void construction_nullStatus_throws() {
assertThrows(NullPointerException.class, () -> assertThrows(NullPointerException.class, () ->
new GuiBatchRunResultRow("test.pdf", FP, null, new GuiBatchRunResultRow("test.pdf", FP, null,
null, null, null, Duration.ZERO)); null, null, null, null, Duration.ZERO));
} }
@Test @Test
void construction_negativeDuration_throws() { void construction_negativeDuration_throws() {
assertThrows(IllegalArgumentException.class, () -> assertThrows(IllegalArgumentException.class, () ->
new GuiBatchRunResultRow("test.pdf", FP, DocumentCompletionStatus.SUCCESS, new GuiBatchRunResultRow("test.pdf", FP, DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ofSeconds(-1))); null, null, null, null, Duration.ofSeconds(-1)));
} }
@Test @Test
void construction_nullOptionals_normalisedToEmpty() { void construction_nullOptionals_normalisedToEmpty() {
GuiBatchRunResultRow row = new GuiBatchRunResultRow( GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT, "test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
null, null, null, Duration.ZERO); null, null, null, null, Duration.ZERO);
assertTrue(row.finalFileName().isEmpty()); assertTrue(row.finalFileName().isEmpty());
assertTrue(row.resolvedDate().isEmpty()); assertTrue(row.resolvedDate().isEmpty());
assertTrue(row.aiReasoning().isEmpty()); assertTrue(row.aiReasoning().isEmpty());
assertTrue(row.aiFailureMessage().isEmpty());
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -144,12 +146,13 @@ class GuiBatchRunResultRowTest {
GuiBatchRunResultRow original = new GuiBatchRunResultRow( GuiBatchRunResultRow original = new GuiBatchRunResultRow(
"test.pdf", FP, DocumentCompletionStatus.SUCCESS, "test.pdf", FP, DocumentCompletionStatus.SUCCESS,
Optional.of("2026-01-01 - Titel.pdf"), Optional.empty(), Optional.empty(), Optional.of("2026-01-01 - Titel.pdf"), Optional.empty(), Optional.empty(),
Duration.ofMillis(42)); Optional.empty(), Duration.ofMillis(42));
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(original); GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(original);
assertTrue(marker.finalFileName().isEmpty()); assertTrue(marker.finalFileName().isEmpty());
assertTrue(marker.resolvedDate().isEmpty()); assertTrue(marker.resolvedDate().isEmpty());
assertTrue(marker.aiReasoning().isEmpty()); assertTrue(marker.aiReasoning().isEmpty());
assertTrue(marker.aiFailureMessage().isEmpty());
assertEquals(Duration.ZERO, marker.processingDuration()); 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 // Helper
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) { private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
return new GuiBatchRunResultRow( return new GuiBatchRunResultRow(
"file.pdf", FP, status, null, null, null, Duration.ofMillis(1)); "file.pdf", FP, status, null, null, null, null, Duration.ofMillis(1));
} }
} }
@@ -188,6 +188,7 @@ class GuiBatchRunTabSelectionSmokeTest {
"2026-01-01 - Titel.pdf", "2026-01-01 - Titel.pdf",
java.time.LocalDate.of(2026, 1, 1), java.time.LocalDate.of(2026, 1, 1),
"reasoning", "reasoning",
null,
Duration.ofMillis(5))); Duration.ofMillis(5)));
observer.onRunEnded(new RunSummary(1, 0, 0)); observer.onRunEnded(new RunSummary(1, 0, 0));
return GuiBatchRunLaunchOutcome.completed(); return GuiBatchRunLaunchOutcome.completed();
@@ -286,7 +287,7 @@ class GuiBatchRunTabSelectionSmokeTest {
private static GuiBatchRunResultRow row(String name, DocumentFingerprint fp, private static GuiBatchRunResultRow row(String name, DocumentFingerprint fp,
DocumentCompletionStatus status) { DocumentCompletionStatus status) {
return new GuiBatchRunResultRow(name, fp, 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 { private void runOnFx(Runnable action) throws InterruptedException {
@@ -101,13 +101,14 @@ class GuiBatchRunTabSmokeTest {
"2026-03-01 - Titel.pdf", "2026-03-01 - Titel.pdf",
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
"gut begründet", "gut begründet",
null,
Duration.ofMillis(42))); Duration.ofMillis(42)));
observer.onDocumentCompleted(new DocumentCompletionEvent( observer.onDocumentCompleted(new DocumentCompletionEvent(
"b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_RETRYABLE, "b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_RETRYABLE,
null, null, null, Duration.ofMillis(10))); null, null, null, null, Duration.ofMillis(10)));
observer.onDocumentCompleted(new DocumentCompletionEvent( observer.onDocumentCompleted(new DocumentCompletionEvent(
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, "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)); observer.onRunEnded(new RunSummary(1, 1, 1));
return GuiBatchRunLaunchOutcome.completed(); return GuiBatchRunLaunchOutcome.completed();
}; };
@@ -31,6 +31,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* any is available for this candidate (may be present on success * any is available for this candidate (may be present on success
* and on some failure paths where an AI call had previously * and on some failure paths where an AI call had previously
* produced a reasoning); {@code null} when no reasoning exists * 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 * @param processingDuration the wall-clock duration spent on this candidate in the current
* run; never {@code null} and never negative * run; never {@code null} and never negative
*/ */
@@ -41,6 +44,7 @@ public record DocumentCompletionEvent(
String finalFileName, String finalFileName,
LocalDate resolvedDate, LocalDate resolvedDate,
String aiReasoning, String aiReasoning,
String failureMessage,
Duration processingDuration) { Duration processingDuration) {
/** /**
@@ -618,6 +618,7 @@ public class DocumentProcessingCoordinator {
resolvedFilename, resolvedFilename,
proposalAttempt.resolvedDate(), proposalAttempt.resolvedDate(),
proposalAttempt.aiReasoning(), proposalAttempt.aiReasoning(),
null,
attemptStart, now); attemptStart, now);
return true; return true;
@@ -701,7 +702,7 @@ public class DocumentProcessingCoordinator {
publishCompletion(candidate, fingerprint, publishCompletion(candidate, fingerprint,
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
: DocumentCompletionStatus.FAILED_PERMANENT, : DocumentCompletionStatus.FAILED_PERMANENT,
null, null, null, attemptStart, now); null, null, null, errorMessage, attemptStart, now);
return true; return true;
} catch (DocumentPersistenceException persistEx) { } catch (DocumentPersistenceException persistEx) {
@@ -771,7 +772,7 @@ public class DocumentProcessingCoordinator {
transition.retryable() transition.retryable()
? DocumentCompletionStatus.FAILED_RETRYABLE ? DocumentCompletionStatus.FAILED_RETRYABLE
: DocumentCompletionStatus.FAILED_PERMANENT, : DocumentCompletionStatus.FAILED_PERMANENT,
null, null, reasoning, attemptStart, now); null, null, reasoning, errorMessage, attemptStart, now);
if (!secondaryPersisted) { if (!secondaryPersisted) {
logger.debug("Completion for '{}' reported without secondary persistence record.", logger.debug("Completion for '{}' reported without secondary persistence record.",
@@ -815,7 +816,7 @@ public class DocumentProcessingCoordinator {
logger.debug("Skip attempt #{} persisted for '{}' with status {}.", logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
attemptNumber, candidate.uniqueIdentifier(), skipStatus); attemptNumber, candidate.uniqueIdentifier(), skipStatus);
publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED, publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED,
null, null, null, attemptStart, now); null, null, null, null, attemptStart, now);
return true; return true;
} catch (DocumentPersistenceException e) { } catch (DocumentPersistenceException e) {
@@ -1080,12 +1081,17 @@ public class DocumentProcessingCoordinator {
outcome.counters().contentErrorCount(), outcome.counters().contentErrorCount(),
outcome.counters().transientErrorCount()); outcome.counters().transientErrorCount());
} }
// Pipeline-path terminal resolutions are reported to the progress observer. // Pipeline-Pfad: terminale Auflösungen werden dem Progress-Observer gemeldet.
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes // PROPOSAL_READY ist ein Zwischenstatus; die Finalisierung publiziert das eigentliche
// the actual completion event (SUCCESS or transient-error failure). // Completion-Event (SUCCESS oder Fehler).
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) { 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), publishCompletion(candidate, fingerprint, toCompletionStatus(outcome),
null, null, null, attemptStart, now); null, null, null, pipelineFailureMessage, attemptStart, now);
} }
return true; 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 * Any runtime exception thrown by the observer is caught and logged at warn level and must
* not affect persistence or batch flow. * not affect persistence or batch flow.
* *
* @param candidate the candidate being reported; must not be null * @param candidate die Kandidatdatei, über die berichtet wird; darf nicht null sein
* @param fingerprint the content-based identity of the document; must not be null * @param fingerprint der inhaltsbasierte Fingerprint des Dokuments; darf nicht null sein
* @param status the aggregated completion status; must not be null * @param status der aggregierte Abschlussstatus; darf nicht null sein
* @param finalFileName the final target filename on success; {@code null} otherwise * @param finalFileName der finale Zieldateiname bei Erfolg; sonst {@code null}
* @param resolvedDate the resolved date on success; may be {@code null} otherwise * @param resolvedDate das aufgelöste Datum bei Erfolg; bei Fehlern kann es {@code null} sein
* @param aiReasoning the AI reasoning when one is available for this result; * @param aiReasoning das KI-Reasoning, wenn für dieses Ergebnis eines vorliegt;
* {@code null} otherwise * sonst {@code null}
* @param startInstant the moment processing of the candidate began in this run * @param failureMessage eine lesbare Fehlerbeschreibung bei Fehler-Status; bei Erfolg
* @param endInstant the moment the terminal resolution was reached * 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( private void publishCompletion(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
@@ -1233,6 +1241,7 @@ public class DocumentProcessingCoordinator {
String finalFileName, String finalFileName,
LocalDate resolvedDate, LocalDate resolvedDate,
String aiReasoning, String aiReasoning,
String failureMessage,
Instant startInstant, Instant startInstant,
Instant endInstant) { Instant endInstant) {
Consumer<DocumentCompletionEvent> forwarder = completionForwarder; Consumer<DocumentCompletionEvent> forwarder = completionForwarder;
@@ -1251,9 +1260,10 @@ public class DocumentProcessingCoordinator {
finalFileName, finalFileName,
resolvedDate, resolvedDate,
aiReasoning, aiReasoning,
failureMessage,
duration)); duration));
} catch (RuntimeException forwarderFailure) { } 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); candidate.uniqueIdentifier(), forwarderFailure.getMessage(), forwarderFailure);
} }
} }
@@ -85,13 +85,13 @@ class BatchRunProgressObservationTest {
@Test @Test
void documentCompletionEvent_rejectsBlankFilename() { void documentCompletionEvent_rejectsBlankFilename() {
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent( 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 @Test
void documentCompletionEvent_rejectsNegativeDuration() { void documentCompletionEvent_rejectsNegativeDuration() {
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent( 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))); Duration.ofSeconds(-1)));
} }
@@ -99,7 +99,7 @@ class BatchRunProgressObservationTest {
void documentCompletionEvent_carriesOptionalFields() { void documentCompletionEvent_carriesOptionalFields() {
DocumentCompletionEvent event = new DocumentCompletionEvent( DocumentCompletionEvent event = new DocumentCompletionEvent(
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf", "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("x.pdf", event.originalFileName());
assertEquals(DocumentCompletionStatus.SUCCESS, event.status()); assertEquals(DocumentCompletionStatus.SUCCESS, event.status());
@@ -129,7 +129,7 @@ class BatchRunProgressObservationTest {
assertSame(a, b); assertSame(a, b);
a.onRunStarted(new RunId("r-1"), 5); a.onRunStarted(new RunId("r-1"), 5);
a.onDocumentCompleted(new DocumentCompletionEvent( 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)); a.onRunEnded(new RunSummary(0, 0, 0));
} }
@@ -438,7 +438,7 @@ class BatchRunProgressObservationTest {
candidate.uniqueIdentifier(), candidate.uniqueIdentifier(),
fingerprint, fingerprint,
statuses.get(index), statuses.get(index),
null, null, null, Duration.ofMillis(10))); null, null, null, null, Duration.ofMillis(10)));
} }
onBeforeReturn.run(); onBeforeReturn.run();
return true; return true;