Fix #41: Historischen KI-Dateinamen für übersprungene Dokumente in Ergebnistabelle anzeigen

Neue Komponenten:
- ResolveHistoricalFileNameUseCase (port/in) und DefaultResolveHistoricalFileNameUseCase (usecase)
- GuiHistoricalFileNamePort (GUI-interner Port, folgt dem Muster von GuiManualFileRenamePort)

GuiBatchRunCoordinator ruft in toRow() für SKIPPED-Zeilen ohne finalName den
historicalFileNamePort auf und trägt den Rückgabewert als neuen Dateinamen ein.

Bootstrap verdrahtet resolveHistoricalFileNameForGui als GuiHistoricalFileNamePort
und übergibt ihn über GuiStartupContext an den GUI-Adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 10:54:31 +02:00
parent 385bda5331
commit 1db6e27be8
10 changed files with 490 additions and 25 deletions
@@ -17,6 +17,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalFileNamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
@@ -371,6 +372,12 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiManualFileRenamePort manualFileRenamePort;
/**
* Port used by the processing-run coordinator to resolve the historical AI-proposed
* filename for skipped documents. Supplied by Bootstrap via the startup context.
*/
private final GuiHistoricalFileNamePort historicalFileNamePort;
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -446,6 +453,7 @@ public final class GuiConfigurationEditorWorkspace {
this.miniRunLauncher = effectiveContext.miniRunLauncher();
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.historicalFileNamePort = effectiveContext.historicalFileNamePort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -454,6 +462,7 @@ public final class GuiConfigurationEditorWorkspace {
this::isSavedConfigurationReady,
this::applyBatchRunLockState,
() -> this.manualFileRenamePort,
() -> this.historicalFileNamePort,
this::editorSourceFolder,
this::editorTargetFolder);
@@ -6,6 +6,7 @@ import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalFileNamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
@@ -40,7 +41,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI.
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI, and
* the {@link GuiHistoricalFileNamePort} used to retrieve the historical AI-proposed filename
* for documents that were skipped in the current run.
* <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -59,7 +62,8 @@ public record GuiStartupContext(
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort) {
GuiManualFileRenamePort manualFileRenamePort,
GuiHistoricalFileNamePort historicalFileNamePort) {
/**
* Creates a fully wired startup context.
@@ -81,6 +85,8 @@ public record GuiStartupContext(
* documents; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
* must not be {@code null}
* @param historicalFileNamePort bridge that resolves the historical AI-proposed filename
* for skipped documents; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -109,6 +115,8 @@ public record GuiStartupContext(
"resetDocumentStatusPort must not be null");
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
"manualFileRenamePort must not be null");
historicalFileNamePort = Objects.requireNonNull(historicalFileNamePort,
"historicalFileNamePort must not be null");
}
/**
@@ -148,7 +156,8 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort());
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
noOpHistoricalFileNamePort());
}
/**
@@ -182,7 +191,8 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort());
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
noOpHistoricalFileNamePort());
}
/**
@@ -217,7 +227,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort());
rejectingManualFileRenamePort(), noOpHistoricalFileNamePort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -246,6 +256,10 @@ public record GuiStartupContext(
"Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
}
private static GuiHistoricalFileNamePort noOpHistoricalFileNamePort() {
return (configPath, fingerprint) -> java.util.Optional.empty();
}
/**
* Creates a blank startup context with no-op implementations for all ports and services.
* <p>
@@ -319,6 +333,7 @@ public record GuiStartupContext(
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
rejectingResetPort(),
rejectingManualFileRenamePort());
rejectingManualFileRenamePort(),
noOpHistoricalFileNamePort());
}
}
@@ -17,6 +17,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
@@ -112,6 +113,7 @@ public final class GuiBatchRunCoordinator {
private final Function<Runnable, Thread> threadFactory;
private final Consumer<Runnable> fxDispatcher;
private final Listener listener;
private final GuiHistoricalFileNamePort historicalFileNamePort;
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
@@ -152,6 +154,27 @@ public final class GuiBatchRunCoordinator {
defaultThreadFactory(), defaultFxDispatcher(), listener);
}
/**
* Creates the coordinator with all ports and the historical file name port, using the
* default worker-thread factory and JavaFX Application Thread dispatcher.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param listener GUI listener invoked on the FX thread; must not be null
* @param historicalFileNamePort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Listener listener,
GuiHistoricalFileNamePort historicalFileNamePort) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalFileNamePort);
}
/**
* Creates the coordinator with custom hooks for the worker-thread factory and the
* UI-thread dispatcher.
@@ -176,12 +199,43 @@ public final class GuiBatchRunCoordinator {
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener) {
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
noOpHistoricalFileNamePort());
}
/**
* Creates the coordinator with all ports, custom thread factory, FX dispatcher and
* historical file name port.
* <p>
* This is the canonical constructor. All other constructors delegate here.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
* @param historicalFileNamePort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalFileNamePort historicalFileNamePort) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
this.listener = Objects.requireNonNull(listener, "listener must not be null");
this.historicalFileNamePort = Objects.requireNonNull(
historicalFileNamePort, "historicalFileNamePort must not be null");
}
/**
@@ -382,7 +436,7 @@ public final class GuiBatchRunCoordinator {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath);
observerSummary.set(null);
BatchRunProgressObserver observer = buildDispatchingObserver();
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
@@ -405,7 +459,7 @@ public final class GuiBatchRunCoordinator {
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
observerSummary.set(null);
BatchRunProgressObserver observer = buildDispatchingObserver();
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
@@ -476,7 +530,7 @@ public final class GuiBatchRunCoordinator {
*/
private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();
private BatchRunProgressObserver buildDispatchingObserver() {
private BatchRunProgressObserver buildDispatchingObserver(Path configFilePath) {
return new BatchRunProgressObserver() {
@Override
public void onRunStarted(RunId runId, int totalCandidates) {
@@ -485,23 +539,44 @@ public final class GuiBatchRunCoordinator {
@Override
public void onDocumentCompleted(DocumentCompletionEvent event) {
GuiBatchRunResultRow row = toRow(event);
GuiBatchRunResultRow row = toRow(event, configFilePath);
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
}
@Override
public void onRunEnded(RunSummary summary) {
observerSummary.set(summary);
// No FX dispatch here: the worker thread invokes the listener's
// onRunEnded via finishRun() once the launcher has returned, ensuring
// the outcome carries the launcher's terminal verdict.
// Kein FX-Dispatch hier: der Worker-Thread ruft onRunEnded über finishRun()
// auf, nachdem der Launcher zurückgekehrt ist.
}
};
}
private static GuiBatchRunResultRow toRow(DocumentCompletionEvent event) {
/**
* Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
* <p>
* Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED}) ohne
* vorhandenen Dateinamen wird der historische KI-Dateiname über den
* {@link GuiHistoricalFileNamePort} nachgeladen. Schlägt die Abfrage fehl, bleibt
* die Spalte leer. Die Methode läuft auf dem Worker-Thread.
*
* @param event das abgeschlossene Kandidatenereignis; darf nicht {@code null} sein
* @param configFilePath Pfad zur aktiven Konfigurationsdatei; darf nicht {@code null} sein
* @return eine neue {@link GuiBatchRunResultRow}; nie {@code null}
*/
private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
Optional<String> finalName = event.finalFileName() == null
? Optional.empty() : Optional.of(event.finalFileName());
// Historischen KI-Dateinamen für übersprungene Dokumente nachladen
if (finalName.isEmpty() && event.status() == DocumentCompletionStatus.SKIPPED) {
try {
finalName = historicalFileNamePort.resolveHistoricalFileName(
configFilePath, event.fingerprint());
} catch (Exception e) {
LOG.warn("Historischer Dateiname konnte nicht abgefragt werden für {}: {}",
event.originalFileName(), e.getMessage(), e);
}
}
Optional<LocalDate> date = event.resolvedDate() == null
? Optional.empty() : Optional.of(event.resolvedDate());
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
@@ -520,6 +595,10 @@ public final class GuiBatchRunCoordinator {
duration);
}
private static GuiHistoricalFileNamePort noOpHistoricalFileNamePort() {
return (configPath, fingerprint) -> Optional.empty();
}
private static Function<Runnable, Thread> defaultThreadFactory() {
return task -> {
Thread thread = new Thread(task, WORKER_THREAD_NAME);
@@ -193,6 +193,7 @@ public final class GuiBatchRunTab {
private final Runnable onRunStateChanged;
private final GuiBatchRunCoordinator coordinator;
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
private final Supplier<GuiHistoricalFileNamePort> historicalFileNamePortSupplier;
private final Supplier<Optional<Path>> sourceFolderSupplier;
private final Supplier<Optional<String>> targetFolderSupplier;
@@ -229,12 +230,14 @@ public final class GuiBatchRunTab {
* null sein
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
* null sein
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbennennungs-Port;
* darf nicht null sein
* @param sourceFolderSupplier Supplier für den konfigurierten Quellordner;
* darf leeres Optional zurückliefern
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
* Pfad-String; darf leeres Optional zurückliefern
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbennennungs-Port;
* darf nicht null sein
* @param historicalFileNamePortSupplier Supplier für den historischen Dateiname-Port;
* darf nicht null sein
* @param sourceFolderSupplier Supplier für den konfigurierten Quellordner;
* darf leeres Optional zurückliefern
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
* Pfad-String; darf leeres Optional zurückliefern
*/
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
@@ -243,6 +246,7 @@ public final class GuiBatchRunTab {
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged,
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
Supplier<GuiHistoricalFileNamePort> historicalFileNamePortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) {
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
@@ -254,6 +258,8 @@ public final class GuiBatchRunTab {
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
this.manualFileRenamePortSupplier = Objects.requireNonNull(
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
this.historicalFileNamePortSupplier = Objects.requireNonNull(
historicalFileNamePortSupplier, "historicalFileNamePortSupplier must not be null");
this.sourceFolderSupplier = Objects.requireNonNull(
sourceFolderSupplier, "sourceFolderSupplier must not be null");
this.targetFolderSupplier = Objects.requireNonNull(
@@ -266,7 +272,8 @@ public final class GuiBatchRunTab {
miniRunLauncherSupplier.get().launch(configPath, filter, observer, token),
(configPath, fingerprints) ->
resetPortSupplier.get().reset(configPath, fingerprints),
new CoordinatorListener());
new CoordinatorListener(),
historicalFileNamePortSupplier.get());
this.tab.setClosable(false);
this.tab.setContent(buildContent());
@@ -306,6 +313,7 @@ public final class GuiBatchRunTab {
savedConfigurationReadyCheck,
onRunStateChanged,
() -> GuiBatchRunTab::rejectingRename,
() -> (cfgPath, fp) -> Optional.empty(),
Optional::empty,
Optional::empty);
}
@@ -338,6 +346,7 @@ public final class GuiBatchRunTab {
savedConfigurationReadyCheck,
onRunStateChanged,
() -> GuiBatchRunTab::rejectingRename,
() -> (cfgPath, fp) -> Optional.empty(),
Optional::empty,
Optional::empty);
}
@@ -0,0 +1,38 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-interner Port zum Abfragen des historischen KI-Dateinamens einer Quelldatei.
* <p>
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED})
* den aus einem früheren Lauf bekannten Zieldateinamen nachzuschlagen und in der Spalte
* „Neuer Dateiname" der Ergebnistabelle anzuzeigen.
* <p>
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die Konfiguration
* aus {@code configFilePath}, baut den zugehörigen Use-Case auf und gibt das Ergebnis
* zurück. Technische Fehler beim Laden oder Abfragen dürfen nicht als Exception propagiert
* werden; sie werden intern behandelt und als leeres {@link Optional} zurückgegeben.
* <p>
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
* und darf blockieren.
*/
@FunctionalInterface
public interface GuiHistoricalFileNamePort {
/**
* Gibt den letzten erfolgreich geschriebenen Zieldateinamen für das durch
* {@code fingerprint} identifizierte Dokument zurück, oder ein leeres
* {@link Optional}, wenn kein solcher Name verfügbar ist.
*
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
* darf nicht {@code null} sein
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
* @return der historische Zieldateiname, oder leer wenn nicht vorhanden
*/
Optional<String> resolveHistoricalFileName(Path configFilePath, DocumentFingerprint fingerprint);
}