From 1db6e27be835cbd83694ca878e5a31eb6a5b496a Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 27 Apr 2026 10:54:31 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#41:=20Historischen=20KI-Dateinamen=20f?= =?UTF-8?q?=C3=BCr=20=C3=BCbersprungene=20Dokumente=20in=20Ergebnistabelle?= =?UTF-8?q?=20anzeigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../gui/GuiConfigurationEditorWorkspace.java | 9 ++ .../adapter/in/gui/GuiStartupContext.java | 27 +++- .../gui/batchrun/GuiBatchRunCoordinator.java | 95 ++++++++++- .../in/gui/batchrun/GuiBatchRunTab.java | 23 ++- .../batchrun/GuiHistoricalFileNamePort.java | 38 +++++ .../in/ResolveHistoricalFileNameUseCase.java | 37 +++++ .../application/port/in/package-info.java | 7 + ...faultResolveHistoricalFileNameUseCase.java | 69 ++++++++ ...tResolveHistoricalFileNameUseCaseTest.java | 152 ++++++++++++++++++ .../umbenenner/bootstrap/BootstrapRunner.java | 58 ++++++- 10 files changed, 490 insertions(+), 25 deletions(-) create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 15d718a..1e5da5b 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -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); diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 4d5eb45..5e47a3d 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -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. *

* 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. *

@@ -319,6 +333,7 @@ public record GuiStartupContext( noOpBatchRunLauncher, rejectingMiniRunLauncher(), rejectingResetPort(), - rejectingManualFileRenamePort()); + rejectingManualFileRenamePort(), + noOpHistoricalFileNamePort()); } } 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 3d1c16a..8616360 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 @@ -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 threadFactory; private final Consumer fxDispatcher; private final Listener listener; + private final GuiHistoricalFileNamePort historicalFileNamePort; private final AtomicReference 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 threadFactory, Consumer 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. + *

+ * 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 threadFactory, + Consumer 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 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. + *

+ * 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 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 date = event.resolvedDate() == null ? Optional.empty() : Optional.of(event.resolvedDate()); Optional 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 defaultThreadFactory() { return task -> { Thread thread = new Thread(task, WORKER_THREAD_NAME); 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 fdd4807..a3f8aa6 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 @@ -193,6 +193,7 @@ public final class GuiBatchRunTab { private final Runnable onRunStateChanged; private final GuiBatchRunCoordinator coordinator; private final Supplier manualFileRenamePortSupplier; + private final Supplier historicalFileNamePortSupplier; private final Supplier> sourceFolderSupplier; private final Supplier> 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 launcherSupplier, Supplier miniRunLauncherSupplier, @@ -243,6 +246,7 @@ public final class GuiBatchRunTab { BooleanSupplier savedConfigurationReadyCheck, Runnable onRunStateChanged, Supplier manualFileRenamePortSupplier, + Supplier historicalFileNamePortSupplier, Supplier> sourceFolderSupplier, Supplier> 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); } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java new file mode 100644 index 0000000..bb6d467 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java @@ -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. + *

+ * 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. + *

+ * 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. + *

+ * 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 resolveHistoricalFileName(Path configFilePath, DocumentFingerprint fingerprint); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java new file mode 100644 index 0000000..3bf0a39 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java @@ -0,0 +1,37 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +import java.util.Optional; + +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; + +/** + * Inbound port for resolving the historical target filename for a previously processed document. + *

+ * Used to populate the "Neuer Dateiname" column in the GUI result table for documents that + * were skipped in the current run because they were already in a terminal state from a + * previous run. For documents that previously reached + * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}, the last + * successfully written target filename is returned. For all other terminal states + * (e.g. {@code FAILED_FINAL}) that were never copied to the target folder, an empty + * {@link Optional} is returned. + *

+ * Architecture boundary: Implementations of this port must not expose + * JDBC, SQLite, filesystem or HTTP types through this interface. All infrastructure details + * remain in the adapter layer. + */ +public interface ResolveHistoricalFileNameUseCase { + + /** + * Returns the last successfully written target filename for the document identified + * by the given fingerprint, or an empty {@link Optional} if no such filename exists. + *

+ * The method never throws: technical failures during the repository lookup are caught + * and result in an empty return value. + * + * @param fingerprint content-based document identity; must not be {@code null} + * @return the last target filename written for this document, or empty if the document + * never reached a successful terminal state or the lookup failed + * @throws NullPointerException if {@code fingerprint} is {@code null} + */ + Optional resolveHistoricalFileName(DocumentFingerprint fingerprint); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java index 9306486..c99bd79 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java @@ -28,6 +28,13 @@ * — Event and summary value types carried to the observer * *

+ *

+ * Query use cases (for GUI adapters): + *

+ *

* Architecture Rule: Inbound ports are independent of implementation and contain no business logic. * They define "what can be done to the application". All dependencies point inward; * adapters depend on ports, not vice versa. diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java new file mode 100644 index 0000000..ea42942 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java @@ -0,0 +1,69 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import java.util.Objects; +import java.util.Optional; + +import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; + +/** + * Default implementation of {@link ResolveHistoricalFileNameUseCase}. + *

+ * Queries the {@link DocumentRecordRepository} for the master record of the given fingerprint. + * If the record represents a document that previously reached a successful terminal state, + * the last known target filename ({@code lastTargetFileName}) is returned. + *

+ * For all other terminal states (e.g. documents that finally failed without ever producing + * a target copy) or when no master record exists, an empty {@link Optional} is returned. + * Technical failures during the repository lookup are caught silently and treated as + * an absent result so that the calling GUI layer is never forced to handle exceptions + * from this query path. + */ +public class DefaultResolveHistoricalFileNameUseCase implements ResolveHistoricalFileNameUseCase { + + private final DocumentRecordRepository documentRecordRepository; + + /** + * Creates the use case with the required document master record repository. + * + * @param documentRecordRepository repository for reading document master records; + * must not be {@code null} + * @throws NullPointerException if {@code documentRecordRepository} is {@code null} + */ + public DefaultResolveHistoricalFileNameUseCase( + DocumentRecordRepository documentRecordRepository) { + this.documentRecordRepository = Objects.requireNonNull( + documentRecordRepository, "documentRecordRepository must not be null"); + } + + /** + * Returns the last successfully written target filename for the given fingerprint, + * or an empty {@link Optional} if no such filename exists. + *

+ * The method queries the document master record. Only documents with a prior + * successful terminal state carry a non-null {@code lastTargetFileName}. For all + * other states (unknown, processable, finally failed) and on any technical lookup + * failure, an empty {@link Optional} is returned. + * + * @param fingerprint content-based document identity; must not be {@code null} + * @return the historical target filename, or empty if not available + * @throws NullPointerException if {@code fingerprint} is {@code null} + */ + @Override + public Optional resolveHistoricalFileName(DocumentFingerprint fingerprint) { + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + try { + DocumentRecordLookupResult result = + documentRecordRepository.findByFingerprint(fingerprint); + if (result instanceof DocumentTerminalSuccess success) { + return Optional.ofNullable(success.record().lastTargetFileName()); + } + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java new file mode 100644 index 0000000..f8a0440 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java @@ -0,0 +1,152 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown; +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; + +/** + * Unit tests for {@link DefaultResolveHistoricalFileNameUseCase}. + */ +class DefaultResolveHistoricalFileNameUseCaseTest { + + private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64)); + + @Test + void constructor_withNullRepository_throws() { + assertThatNullPointerException() + .isThrownBy(() -> new DefaultResolveHistoricalFileNameUseCase(null)) + .withMessageContaining("documentRecordRepository"); + } + + @Test + void resolveHistoricalFileName_withNullFingerprint_throws() { + var useCase = new DefaultResolveHistoricalFileNameUseCase(stubRepo(new DocumentUnknown())); + assertThatNullPointerException() + .isThrownBy(() -> useCase.resolveHistoricalFileName(null)) + .withMessageContaining("fingerprint"); + } + + @Test + void resolveHistoricalFileName_forSuccessRecord_returnsLastTargetFileName() { + DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, "2026-01-01 - Rechnung.pdf"); + var useCase = new DefaultResolveHistoricalFileNameUseCase( + stubRepo(new DocumentTerminalSuccess(record))); + + Optional result = useCase.resolveHistoricalFileName(FP); + assertThat(result).contains("2026-01-01 - Rechnung.pdf"); + } + + @Test + void resolveHistoricalFileName_forSuccessRecordWithNullLastTargetFileName_returnsEmpty() { + DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, null); + var useCase = new DefaultResolveHistoricalFileNameUseCase( + stubRepo(new DocumentTerminalSuccess(record))); + + assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty(); + } + + @Test + void resolveHistoricalFileName_forUnknownDocument_returnsEmpty() { + var useCase = new DefaultResolveHistoricalFileNameUseCase(stubRepo(new DocumentUnknown())); + + assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty(); + } + + @Test + void resolveHistoricalFileName_forFinalFailure_returnsEmpty() { + DocumentRecord record = buildRecord(ProcessingStatus.FAILED_FINAL, null); + var useCase = new DefaultResolveHistoricalFileNameUseCase( + stubRepo(new DocumentTerminalFinalFailure(record))); + + assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty(); + } + + @Test + void resolveHistoricalFileName_forProcessableDocument_returnsEmpty() { + DocumentRecord record = buildRecord(ProcessingStatus.READY_FOR_AI, null); + var useCase = new DefaultResolveHistoricalFileNameUseCase( + stubRepo(new DocumentKnownProcessable(record))); + + assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty(); + } + + @Test + void resolveHistoricalFileName_forPersistenceLookupFailure_returnsEmpty() { + var useCase = new DefaultResolveHistoricalFileNameUseCase( + stubRepo(new PersistenceLookupTechnicalFailure("DB-Fehler", null))); + + assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty(); + } + + @Test + void resolveHistoricalFileName_whenRepositoryThrows_returnsEmpty() { + DocumentRecordRepository throwingRepo = new DocumentRecordRepository() { + @Override + public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) { + throw new DocumentPersistenceException("Verbindungsfehler", null); + } + @Override + public void create(DocumentRecord record) {} + @Override + public void update(DocumentRecord record) {} + @Override + public void deleteByFingerprint(DocumentFingerprint fingerprint) {} + }; + + var useCase = new DefaultResolveHistoricalFileNameUseCase(throwingRepo); + + assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty(); + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden + // ------------------------------------------------------------------------- + + private static DocumentRecordRepository stubRepo(DocumentRecordLookupResult result) { + return new DocumentRecordRepository() { + @Override + public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) { + return result; + } + @Override + public void create(DocumentRecord record) {} + @Override + public void update(DocumentRecord record) {} + @Override + public void deleteByFingerprint(DocumentFingerprint fingerprint) {} + }; + } + + private static DocumentRecord buildRecord(ProcessingStatus status, String lastTargetFileName) { + return new DocumentRecord( + FP, + new SourceDocumentLocator("quell/pfad"), + "original.pdf", + status, + FailureCounters.zero(), + null, + status == ProcessingStatus.SUCCESS ? Instant.now() : null, + Instant.now(), + Instant.now(), + lastTargetFileName != null ? "ziel/ordner" : null, + lastTargetFileName); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 06f9cbb..5955ace 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -24,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext; 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.editor.GuiConfigurationEditorState; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; @@ -58,6 +59,8 @@ import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase; +import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalFileNameUseCase; import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult; import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; @@ -683,6 +686,7 @@ public class BootstrapRunner { de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort = this::resetDocumentStatusForGui; GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename; + GuiHistoricalFileNamePort historicalFileNamePort = this::resolveHistoricalFileNameForGui; if (configPathOverride.isEmpty()) { return new GuiStartupContext( @@ -699,7 +703,8 @@ public class BootstrapRunner { batchRunLauncher, miniRunLauncher, resetPort, - manualRenamePort); + manualRenamePort, + historicalFileNamePort); } Path configPath = Paths.get(configPathOverride.get()); @@ -721,7 +726,8 @@ public class BootstrapRunner { batchRunLauncher, miniRunLauncher, resetPort, - manualRenamePort); + manualRenamePort, + historicalFileNamePort); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); @@ -730,7 +736,7 @@ public class BootstrapRunner { return new GuiStartupContext(loadedState, Optional.empty(), loader, writer, modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, - miniRunLauncher, resetPort, manualRenamePort); + miniRunLauncher, resetPort, manualRenamePort, historicalFileNamePort); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -748,7 +754,8 @@ public class BootstrapRunner { batchRunLauncher, miniRunLauncher, resetPort, - manualRenamePort); + manualRenamePort, + historicalFileNamePort); } } @@ -1062,6 +1069,49 @@ public class BootstrapRunner { } } + /** + * Resolves the historical AI-proposed target filename for a document identified by + * {@code fingerprint}, using the configuration at {@code configFilePath}. + *

+ * Loads the configuration, initialises the schema and delegates to + * {@link ResolveHistoricalFileNameUseCase}. Technical errors during loading or querying + * are caught and returned as an empty {@link Optional}; they are never propagated to the + * caller. + *

+ * Runs on the GUI worker thread. Blocking I/O is therefore acceptable. + * + * @param configFilePath path to the active {@code .properties} file; must not be {@code null} + * @param fingerprint content-based document identity; must not be {@code null} + * @return the last successfully written target filename, or empty if not available + */ + Optional resolveHistoricalFileNameForGui( + Path configFilePath, + DocumentFingerprint fingerprint) { + Objects.requireNonNull(configFilePath, "configFilePath must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + + if (!Files.exists(configFilePath)) { + LOG.debug("Historischer Dateiname: Konfigurationsdatei nicht gefunden: {}", configFilePath); + return Optional.empty(); + } + + try { + migrateConfigurationIfNeeded(configFilePath); + StartConfiguration config = loadAndValidateConfiguration(configFilePath); + initializeSchema(config); + String jdbcUrl = buildJdbcUrl(config); + DocumentRecordRepository documentRecordRepository = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + ResolveHistoricalFileNameUseCase useCase = + new DefaultResolveHistoricalFileNameUseCase(documentRecordRepository); + return useCase.resolveHistoricalFileName(fingerprint); + } catch (Exception e) { + LOG.debug("Historischer Dateiname konnte nicht abgefragt werden für {}: {}", + fingerprint.sha256Hex(), e.getMessage()); + return Optional.empty(); + } + } + /** * Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is * recorded as a failure with the given error message.