Ergaenze zweiten GUI-Tab fuer Verarbeitungslauf mit Live-Fortschritt

- Fuehrt neuen Inbound-Adapter-Subpfad batchrun/ mit Tab, Koordinator,
  Launcher-Port und Ergebniszeilen-Model ein; der Batch-Lauf laeuft auf
  einem Hintergrund-Worker, UI-Updates ausschliesslich via FX-Dispatcher.
- Ergaenzt application.port.in um BatchRunProgressObserver,
  BatchRunCancellationToken, DocumentCompletionEvent/-Status und
  RunSummary; DefaultBatchRunProcessingUseCase und
  DocumentProcessingCoordinator melden Lauf-/Dokument-Ereignisse an den
  Beobachter und unterstuetzen Soft-Stop zwischen Kandidaten.
- Verdrahtet BootstrapRunner so, dass die GUI den vollstaendigen
  Headless-Pipelinepfad (Migration, Validierung, Schema-Init, Lock,
  Use-Case) mit Observer und Cancellation ausfuehrt; headless-Verhalten
  bleibt unveraendert.
- Editor-Workspace bettet den zweiten Tab ein, sperrt Tab 1 mit
  Hinweisbanner waehrend eines Laufs und fragt den Benutzer beim
  Schliessen waehrend eines laufenden Batches.
- Fuegt Tests fuer Observer-Wiring, Koordinator-Lebenszyklus und
  Tab-Smoke-Verhalten ein; aktualisiert die GUI-Bedienanleitung und
  docs/betrieb.md auf den neuen Tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 15:29:06 +02:00
parent eacc205865
commit f4cfb5cbc0
27 changed files with 3621 additions and 93 deletions
@@ -21,6 +21,8 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
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.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
@@ -324,61 +326,86 @@ public class BootstrapRunner {
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
this.validatorFactory = StartConfigurationValidator::new;
this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new;
this.useCaseFactory = (startConfig, lock) -> {
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
String jdbcUrl = buildJdbcUrl(startConfig);
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
ProcessingAttemptRepository processingAttemptRepository =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl);
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
DocumentProcessingCoordinator.class, aiContentSensitivity);
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
DocumentProcessingCoordinator documentProcessingCoordinator =
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
startConfig.maxRetriesTransient(),
startConfig.maxTitleLength(),
activeFamily.getIdentifier());
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter();
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength());
AiNamingService aiNamingService = new AiNamingService(
aiInvocationPort,
promptPort,
aiResponseValidator,
providerConfig.model(),
startConfig.maxTextCharacters(),
startConfig.maxTitleLength());
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
return new DefaultBatchRunProcessingUseCase(
runtimeConfig,
lock,
new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
new PdfTextExtractionPortAdapter(),
fingerprintPort,
documentProcessingCoordinator,
aiNamingService,
useCaseLogger);
};
this.useCaseFactory = (startConfig, lock) -> buildProductionBatchUseCase(
startConfig, lock,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
this.commandFactory = SchedulerBatchCommand::new;
this.guiAdapterFactory = GuiAdapter::new;
}
/**
* Wires the production batch-processing use case with the supplied progress observer and
* cancellation token.
* <p>
* Shared wiring for the headless path (where observer and token default to no-ops) and the
* GUI processing-run path (where both carry live callbacks from the UI). The method is
* intentionally factored out so a single wiring description serves both entry points.
*
* @param startConfig validated startup configuration; must not be null
* @param runLockPort acquired run-lock port; must not be null
* @param progressObserver observer forwarded into the use case; must not be null
* @param cancellationToken cancellation token forwarded into the use case; must not be null
* @return a fully wired production batch use case; never null
*/
private BatchRunProcessingUseCase buildProductionBatchUseCase(
StartConfiguration startConfig,
RunLockPort runLockPort,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
String jdbcUrl = buildJdbcUrl(startConfig);
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
ProcessingAttemptRepository processingAttemptRepository =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl);
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
DocumentProcessingCoordinator.class, aiContentSensitivity);
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
DocumentProcessingCoordinator documentProcessingCoordinator =
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
startConfig.maxRetriesTransient(),
startConfig.maxTitleLength(),
activeFamily.getIdentifier());
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter();
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength());
AiNamingService aiNamingService = new AiNamingService(
aiInvocationPort,
promptPort,
aiResponseValidator,
providerConfig.model(),
startConfig.maxTextCharacters(),
startConfig.maxTitleLength());
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
return new DefaultBatchRunProcessingUseCase(
runtimeConfig,
runLockPort,
new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
new PdfTextExtractionPortAdapter(),
fingerprintPort,
documentProcessingCoordinator,
aiNamingService,
useCaseLogger,
progressObserver,
cancellationToken);
}
/**
* Creates the BootstrapRunner with custom factories for testing, without a migration step.
* <p>
@@ -639,6 +666,7 @@ public class BootstrapRunner {
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
GuiBatchRunLauncher batchRunLauncher = this::launchGuiBatchRun;
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
@@ -651,7 +679,8 @@ public class BootstrapRunner {
providerTechnicalTestService,
pathCheckPort,
technicalTestOrchestrator,
correctionExecutionService);
correctionExecutionService,
batchRunLauncher);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -669,7 +698,8 @@ public class BootstrapRunner {
providerTechnicalTestService,
pathCheckPort,
technicalTestOrchestrator,
correctionExecutionService);
correctionExecutionService,
batchRunLauncher);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -677,7 +707,7 @@ public class BootstrapRunner {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService);
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -691,10 +721,96 @@ public class BootstrapRunner {
providerTechnicalTestService,
pathCheckPort,
technicalTestOrchestrator,
correctionExecutionService);
correctionExecutionService,
batchRunLauncher);
}
}
/**
* Executes exactly one batch run triggered by the GUI's processing-run tab.
* <p>
* Mirrors the headless bootstrap pipeline: legacy migration, configuration loading and
* validation, SQLite schema initialisation, run-lock acquisition, use-case wiring, and
* execution. Forwards the supplied observer and cancellation token into the wired use
* case so the GUI receives live progress callbacks and can request a soft-stop.
* <p>
* All known hard startup failures are mapped to
* {@link GuiBatchRunLaunchOutcome#rejected(String)}. Unexpected runtime exceptions
* that escape after startup are mapped to
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} so the GUI can reach the
* defined terminal state instead of becoming stuck.
*
* @param configFilePath path to the {@code .properties} configuration; must exist on disk
* @param progressObserver observer forwarded into the use case; must not be null
* @param cancellationToken token forwarded into the use case; must not be null
* @return the outcome for the run; never null
*/
GuiBatchRunLaunchOutcome launchGuiBatchRun(
Path configFilePath,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(progressObserver, "progressObserver must not be null");
Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
LOG.info("GUI-Verarbeitungslauf: Startanforderung für Konfiguration {}.", configFilePath);
try {
if (!Files.exists(configFilePath)) {
return GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei wurde nicht gefunden: " + configFilePath);
}
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
BatchRunContext runContext = createRunContext();
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
config, runLockPort, progressObserver, cancellationToken);
BatchRunOutcome outcome = useCase.execute(runContext);
runContext.setEndInstant(Instant.now());
return mapGuiRunOutcome(outcome, runContext);
} catch (ConfigurationLoadingException e) {
LOG.error("GUI-Verarbeitungslauf: Konfiguration konnte nicht geladen werden: {}",
e.getMessage(), e);
return GuiBatchRunLaunchOutcome.rejected(
"Konfiguration konnte nicht geladen werden: " + e.getMessage());
} catch (InvalidStartConfigurationException e) {
LOG.error("GUI-Verarbeitungslauf: Konfiguration ist nicht lauffähig: {}", e.getMessage());
return GuiBatchRunLaunchOutcome.rejected(
"Die gespeicherte Konfiguration ist nicht lauffähig: " + e.getMessage());
} catch (DocumentPersistenceException e) {
LOG.error("GUI-Verarbeitungslauf: SQLite-Initialisierung fehlgeschlagen: {}",
e.getMessage(), e);
return GuiBatchRunLaunchOutcome.rejected(
"SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
} catch (RuntimeException e) {
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
return GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter Fehler im Verarbeitungslauf: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
private GuiBatchRunLaunchOutcome mapGuiRunOutcome(BatchRunOutcome outcome, BatchRunContext runContext) {
return switch (outcome) {
case SUCCESS -> {
LOG.info("GUI-Verarbeitungslauf: Lauf beendet. RunId: {}", runContext.runId());
yield GuiBatchRunLaunchOutcome.completed();
}
case LOCK_UNAVAILABLE -> {
LOG.warn("GUI-Verarbeitungslauf: Laufsperre bereits vergeben. RunId: {}",
runContext.runId());
yield GuiBatchRunLaunchOutcome.rejected(
"Ein anderer Verarbeitungslauf hält bereits die Laufsperre.");
}
case FAILURE -> {
LOG.error("GUI-Verarbeitungslauf: Lauf mit Fehler beendet. RunId: {}",
runContext.runId());
yield GuiBatchRunLaunchOutcome.failedAfterStart(
"Der Verarbeitungslauf konnte nicht erfolgreich abgeschlossen werden.");
}
};
}
/**
* Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
* <p>