V1.1 Änderungen

This commit is contained in:
2026-04-09 05:42:02 +02:00
parent 39800b6ea8
commit 5099ff4aca
44 changed files with 4912 additions and 957 deletions
@@ -0,0 +1,62 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.adapter.out.ai.AnthropicClaudeHttpAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
/**
* Selects and instantiates the active {@link AiInvocationPort} implementation
* based on the configured provider family.
* <p>
* This component lives in the bootstrap layer and is the single point where
* the active provider family is mapped to its corresponding adapter implementation.
* Exactly one provider is selected per application run; the selection is driven
* by the value of {@code ai.provider.active}.
*
* <h2>Registered providers</h2>
* <ul>
* <li>{@link AiProviderFamily#OPENAI_COMPATIBLE} — {@link OpenAiHttpAdapter}</li>
* <li>{@link AiProviderFamily#CLAUDE} — {@link AnthropicClaudeHttpAdapter}</li>
* </ul>
*
* <h2>Hard start failure</h2>
* <p>
* If the requested provider family has no registered implementation, an
* {@link InvalidStartConfigurationException} is thrown immediately, which the
* bootstrap runner maps to exit code 1.
*/
public class AiProviderSelector {
/**
* Selects and constructs the {@link AiInvocationPort} implementation for the given
* provider family using the supplied provider configuration.
*
* @param family the active provider family; must not be {@code null}
* @param config the configuration for the active provider; must not be {@code null}
* @return the constructed adapter instance; never {@code null}
* @throws InvalidStartConfigurationException if no implementation is registered
* for the requested provider family
*/
public AiInvocationPort select(AiProviderFamily family, ProviderConfiguration config) {
Objects.requireNonNull(family, "provider family must not be null");
Objects.requireNonNull(config, "provider configuration must not be null");
if (family == AiProviderFamily.OPENAI_COMPATIBLE) {
return new OpenAiHttpAdapter(config);
}
if (family == AiProviderFamily.CLAUDE) {
return new AnthropicClaudeHttpAdapter(config);
}
throw new InvalidStartConfigurationException(
"No AI adapter implementation registered for provider family: "
+ family.getIdentifier()
+ ". Supported in the current build: openai-compatible, claude");
}
}
@@ -9,11 +9,11 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter;
@@ -27,6 +27,8 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
@@ -68,6 +70,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* configuration is handed to the use case factory which extracts the minimal runtime
* configuration for the application layer.
*
* <h2>Active AI provider</h2>
* <p>
* The active AI provider family is determined from the configuration and logged at run start.
* The {@link AiProviderSelector} in the bootstrap layer selects the appropriate
* {@link AiInvocationPort} implementation. Exactly one provider is active per run.
*
* <h2>Exit code semantics</h2>
* <ul>
* <li>{@code 0}: Batch run executed successfully; individual document failures do not
@@ -82,10 +90,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* <p>
* The production constructor wires the following key adapters:
* <ul>
* <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from properties and environment.</li>
* <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from the multi-provider
* properties schema and environment.</li>
* <li>{@link AiProviderSelector} — selects the active {@link AiInvocationPort} implementation
* based on {@code ai.provider.active}.</li>
* <li>{@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.</li>
* <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema (including target-copy
* schema evolution) at startup.</li>
* <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema at startup.</li>
* <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li>
@@ -103,6 +113,7 @@ public class BootstrapRunner {
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
private final MigrationStep migrationStep;
private final ConfigurationPortFactory configPortFactory;
private final RunLockPortFactory runLockPortFactory;
private final ValidatorFactory validatorFactory;
@@ -110,6 +121,19 @@ public class BootstrapRunner {
private final UseCaseFactory useCaseFactory;
private final CommandFactory commandFactory;
/**
* Functional interface encapsulating the legacy configuration migration step.
* <p>
* The production implementation calls {@link LegacyConfigurationMigrator#migrateIfLegacy}
* on the active configuration file before any configuration is loaded. In tests, a
* no-op lambda is injected so that migration does not interfere with mock configuration ports.
*/
@FunctionalInterface
public interface MigrationStep {
/** Runs the legacy configuration migration if the configuration file is in legacy form. */
void runIfNeeded();
}
/**
* Functional interface for creating a ConfigurationPort.
*/
@@ -175,12 +199,12 @@ public class BootstrapRunner {
* Wires the processing pipeline with the following adapters:
* <ul>
* <li>{@link PropertiesConfigurationPortAdapter} for configuration loading.</li>
* <li>{@link AiProviderSelector} for selecting the active AI provider implementation.</li>
* <li>{@link FilesystemRunLockPortAdapter} for exclusive run locking.</li>
* <li>{@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li>
* <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li>
* <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li>
* <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL and target-copy schema
* evolution at startup.</li>
* <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL at startup.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li>
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
@@ -199,6 +223,8 @@ public class BootstrapRunner {
* begins. Failure during initialisation aborts the run with exit code 1.
*/
public BootstrapRunner() {
this.migrationStep = () -> new LegacyConfigurationMigrator()
.migrateIfLegacy(Paths.get("config/application.properties"));
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
this.validatorFactory = StartConfigurationValidator::new;
@@ -206,7 +232,13 @@ public class BootstrapRunner {
this.useCaseFactory = (startConfig, lock) -> {
// Extract runtime configuration from startup configuration
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
// Select the active AI provider adapter
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();
@@ -216,17 +248,18 @@ public class BootstrapRunner {
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl);
// Wire coordinators logger with AI content sensitivity setting
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(DocumentProcessingCoordinator.class, aiContentSensitivity);
// Wire coordinator logger with AI content sensitivity setting
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.maxRetriesTransient(),
activeFamily.getIdentifier());
// Wire AI naming pipeline
AiInvocationPort aiInvocationPort = new OpenAiHttpAdapter(startConfig);
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter();
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort);
@@ -234,11 +267,12 @@ public class BootstrapRunner {
aiInvocationPort,
promptPort,
aiResponseValidator,
startConfig.apiModel(),
providerConfig.model(),
startConfig.maxTextCharacters());
// Wire use case logger with AI content sensitivity setting
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
return new DefaultBatchRunProcessingUseCase(
runtimeConfig,
lock,
@@ -254,6 +288,9 @@ public class BootstrapRunner {
/**
* Creates the BootstrapRunner with custom factories for testing.
* <p>
* The migration step is set to a no-op; tests that need to exercise the migration
* path use the full seven-parameter constructor.
*
* @param configPortFactory factory for creating ConfigurationPort instances
* @param runLockPortFactory factory for creating RunLockPort instances
@@ -268,6 +305,32 @@ public class BootstrapRunner {
SchemaInitializationPortFactory schemaInitPortFactory,
UseCaseFactory useCaseFactory,
CommandFactory commandFactory) {
this(() -> { /* no-op: tests inject mock ConfigurationPort directly */ },
configPortFactory, runLockPortFactory, validatorFactory,
schemaInitPortFactory, useCaseFactory, commandFactory);
}
/**
* Creates the BootstrapRunner with all factories including an explicit migration step.
* <p>
* Use this constructor in tests that need to exercise the full migration-then-load path.
*
* @param migrationStep the legacy configuration migration step to run before loading
* @param configPortFactory factory for creating ConfigurationPort instances
* @param runLockPortFactory factory for creating RunLockPort instances
* @param validatorFactory factory for creating StartConfigurationValidator instances
* @param schemaInitPortFactory factory for creating PersistenceSchemaInitializationPort instances
* @param useCaseFactory factory for creating BatchRunProcessingUseCase instances
* @param commandFactory factory for creating SchedulerBatchCommand instances
*/
public BootstrapRunner(MigrationStep migrationStep,
ConfigurationPortFactory configPortFactory,
RunLockPortFactory runLockPortFactory,
ValidatorFactory validatorFactory,
SchemaInitializationPortFactory schemaInitPortFactory,
UseCaseFactory useCaseFactory,
CommandFactory commandFactory) {
this.migrationStep = migrationStep;
this.configPortFactory = configPortFactory;
this.runLockPortFactory = runLockPortFactory;
this.validatorFactory = validatorFactory;
@@ -299,6 +362,7 @@ public class BootstrapRunner {
LOG.info("Bootstrap flow started.");
try {
// Bootstrap Phase: prepare configuration and persistence
migrateConfigurationIfNeeded();
StartConfiguration config = loadAndValidateConfiguration();
initializeSchema(config);
// Execution Phase: run batch processing
@@ -318,6 +382,20 @@ public class BootstrapRunner {
}
}
/**
* Runs the legacy configuration migration step exactly once before configuration loading.
* <p>
* If the configuration file is in the legacy flat-key format, it is migrated in-place to the
* multi-provider schema before the normal configuration loading path is entered. If the file
* is already in the current schema, this method returns immediately without any I/O side effect.
* <p>
* A migration failure is a hard startup error and propagates as a
* {@link ConfigurationLoadingException}.
*/
private void migrateConfigurationIfNeeded() {
migrationStep.runIfNeeded();
}
/**
* Loads configuration via {@link ConfigurationPort} and validates it via
* {@link StartConfigurationValidator}.
@@ -329,13 +407,17 @@ public class BootstrapRunner {
* creatable (validator attempts {@code Files.createDirectories} if absent;
* failure here is a hard startup error).</li>
* <li>{@code sqlite.file}: parent directory must exist.</li>
* <li>All numeric and URI constraints.</li>
* <li>All numeric and path constraints.</li>
* </ul>
* <p>
* After successful validation, the active AI provider identifier is logged at INFO level.
*/
private StartConfiguration loadAndValidateConfiguration() {
ConfigurationPort configPort = configPortFactory.create();
StartConfiguration config = configPort.loadConfiguration();
validatorFactory.create().validate(config);
LOG.info("Active AI provider: {}",
config.multiProviderConfiguration().activeProviderFamily().getIdentifier());
return config;
}