diff --git a/config/application-local.example.properties b/config/application-local.example.properties index 468ddf5..93f817d 100644 --- a/config/application-local.example.properties +++ b/config/application-local.example.properties @@ -1,8 +1,8 @@ -# PDF Umbenenner – Konfigurationsbeispiel für lokale Entwicklung +# PDF Umbenenner – Konfigurationsbeispiel fuer lokale Entwicklung # Kopiere diese Datei nach config/application.properties und passe die Werte an. # --------------------------------------------------------------------------- -# Pflichtparameter +# Pflichtparameter (allgemein) # --------------------------------------------------------------------------- # Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden. @@ -13,22 +13,12 @@ source.folder=./work/local/source # Wird automatisch angelegt, wenn er noch nicht existiert. target.folder=./work/local/target -# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie. -# Das übergeordnete Verzeichnis muss vorhanden sein. +# SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie. +# Das uebergeordnete Verzeichnis muss vorhanden sein. sqlite.file=./work/local/pdf-umbenenner.db -# Basis-URL des OpenAI-kompatiblen KI-Dienstes (ohne Pfadsuffix wie /chat/completions). -api.baseUrl=https://api.openai.com/v1 - -# Modellname des KI-Dienstes. -api.model=gpt-4o-mini - -# HTTP-Timeout für KI-Anfragen in Sekunden (muss > 0 sein). -api.timeoutSeconds=30 - # Maximale Anzahl historisierter transienter Fehlversuche pro Dokument. -# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der -# Dokumentstatus auf FAILED_FINAL gesetzt. +# Muss eine ganze Zahl >= 1 sein. max.retries.transient=3 # Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als @@ -42,20 +32,11 @@ max.text.characters=5000 # in der Versuchshistorie. prompt.template.file=./config/prompts/template.txt -# --------------------------------------------------------------------------- -# API-Schlüssel -# --------------------------------------------------------------------------- -# Der API-Schlüssel kann wahlweise über diese Property oder über die -# Umgebungsvariable PDF_UMBENENNER_API_KEY gesetzt werden. -# Die Umgebungsvariable hat Vorrang. -api.key=your-local-api-key-here - # --------------------------------------------------------------------------- # Optionale Parameter # --------------------------------------------------------------------------- -# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen). -# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis. +# Pfad zur Lock-Datei fuer den Startschutz (verhindert parallele Instanzen). runtime.lock.file=./work/local/pdf-umbenenner.lock # Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/. @@ -64,7 +45,42 @@ log.directory=./work/local/logs # Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO. log.level=INFO -# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben. -# Erlaubte Werte: true oder false. Standard ist false (geschützt). -# Nur für Diagnosezwecke auf true setzen. +# Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben. +# Erlaubte Werte: true oder false. Standard ist false (geschuetzt). log.ai.sensitive=false + +# --------------------------------------------------------------------------- +# Aktiver KI-Provider +# --------------------------------------------------------------------------- +# Erlaubte Werte: openai-compatible, claude +ai.provider.active=openai-compatible + +# --------------------------------------------------------------------------- +# OpenAI-kompatibler Provider +# --------------------------------------------------------------------------- +# Basis-URL des KI-Dienstes (ohne Pfadsuffix wie /chat/completions). +ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1 + +# Modellname des KI-Dienstes. +ai.provider.openai-compatible.model=gpt-4o-mini + +# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein). +ai.provider.openai-compatible.timeoutSeconds=30 + +# API-Schluessel. Die Umgebungsvariable OPENAI_COMPATIBLE_API_KEY hat Vorrang. +ai.provider.openai-compatible.apiKey=your-openai-api-key-here + +# --------------------------------------------------------------------------- +# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude) +# --------------------------------------------------------------------------- +# Basis-URL (optional; Standard: https://api.anthropic.com) +# ai.provider.claude.baseUrl=https://api.anthropic.com + +# Modellname (z. B. claude-3-5-sonnet-20241022) +# ai.provider.claude.model=claude-3-5-sonnet-20241022 + +# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein). +# ai.provider.claude.timeoutSeconds=60 + +# API-Schluessel. Die Umgebungsvariable ANTHROPIC_API_KEY hat Vorrang. +# ai.provider.claude.apiKey= diff --git a/config/application-test.example.properties b/config/application-test.example.properties index 4d7a7ab..4862e18 100644 --- a/config/application-test.example.properties +++ b/config/application-test.example.properties @@ -1,71 +1,46 @@ -# PDF Umbenenner – Konfigurationsbeispiel für Testläufe +# PDF Umbenenner – Konfigurationsbeispiel fuer Testlaeufe # Kopiere diese Datei nach config/application.properties und passe die Werte an. -# Diese Vorlage enthält kürzere Timeouts und niedrigere Limits für Testläufe. +# Diese Vorlage enthaelt kuerzere Timeouts und niedrigere Limits fuer Testlaeufe. # --------------------------------------------------------------------------- -# Pflichtparameter +# Pflichtparameter (allgemein) # --------------------------------------------------------------------------- -# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden. -# Der Ordner muss vorhanden und lesbar sein. source.folder=./work/test/source - -# Zielordner: Ordner, in den die umbenannten Kopien abgelegt werden. -# Wird automatisch angelegt, wenn er noch nicht existiert. target.folder=./work/test/target - -# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie. -# Das übergeordnete Verzeichnis muss vorhanden sein. sqlite.file=./work/test/pdf-umbenenner-test.db -# Basis-URL des OpenAI-kompatiblen KI-Dienstes (ohne Pfadsuffix wie /chat/completions). -api.baseUrl=https://api.openai.com/v1 - -# Modellname des KI-Dienstes. -api.model=gpt-4o-mini - -# HTTP-Timeout für KI-Anfragen in Sekunden (muss > 0 sein). -api.timeoutSeconds=10 - -# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument. -# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der -# Dokumentstatus auf FAILED_FINAL gesetzt. max.retries.transient=1 - -# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als -# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf). max.pages=5 - -# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird. max.text.characters=2000 - -# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator -# in der Versuchshistorie. prompt.template.file=./config/prompts/template.txt -# --------------------------------------------------------------------------- -# API-Schlüssel -# --------------------------------------------------------------------------- -# Der API-Schlüssel kann wahlweise über diese Property oder über die -# Umgebungsvariable PDF_UMBENENNER_API_KEY gesetzt werden. -# Die Umgebungsvariable hat Vorrang. -api.key=test-api-key-placeholder - # --------------------------------------------------------------------------- # Optionale Parameter # --------------------------------------------------------------------------- -# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen). -# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis. runtime.lock.file=./work/test/pdf-umbenenner.lock - -# Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/. log.directory=./work/test/logs - -# Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO. log.level=DEBUG - -# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben. -# Erlaubte Werte: true oder false. Standard ist false (geschützt). -# Nur für Diagnosezwecke auf true setzen. log.ai.sensitive=false + +# --------------------------------------------------------------------------- +# Aktiver KI-Provider +# --------------------------------------------------------------------------- +ai.provider.active=openai-compatible + +# --------------------------------------------------------------------------- +# OpenAI-kompatibler Provider +# --------------------------------------------------------------------------- +ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1 +ai.provider.openai-compatible.model=gpt-4o-mini +ai.provider.openai-compatible.timeoutSeconds=10 +ai.provider.openai-compatible.apiKey=test-api-key-placeholder + +# --------------------------------------------------------------------------- +# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude) +# --------------------------------------------------------------------------- +# ai.provider.claude.baseUrl=https://api.anthropic.com +# ai.provider.claude.model=claude-3-5-sonnet-20241022 +# ai.provider.claude.timeoutSeconds=60 +# ai.provider.claude.apiKey=your-anthropic-api-key-here diff --git a/docs/betrieb.md b/docs/betrieb.md index 4521e52..069d4e0 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -53,39 +53,98 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in: - `config/application-local.example.properties` - `config/application-test.example.properties` -### Pflichtparameter +### Pflichtparameter (allgemein) -| Parameter | Beschreibung | -|------------------------|--------------| -| `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) | -| `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) | -| `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) | -| `api.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) | -| `api.model` | Modellname (z. B. `gpt-4o-mini`) | -| `api.timeoutSeconds` | HTTP-Timeout für KI-Anfragen in Sekunden (ganzzahlig, > 0) | -| `max.retries.transient`| Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) | -| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) | -| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) | -| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) | +| Parameter | Beschreibung | +|-------------------------|--------------| +| `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) | +| `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) | +| `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) | +| `ai.provider.active` | Aktiver KI-Provider: `openai-compatible` oder `claude` | +| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) | +| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) | +| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) | +| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) | + +### Provider-Parameter + +Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Provider wird nicht validiert. + +**OpenAI-kompatibler Provider** (`ai.provider.active=openai-compatible`): + +| Parameter | Beschreibung | +|-----------|--------------| +| `ai.provider.openai-compatible.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) | +| `ai.provider.openai-compatible.model` | Modellname (z. B. `gpt-4o-mini`) | +| `ai.provider.openai-compatible.timeoutSeconds` | HTTP-Timeout in Sekunden (ganzzahlig, > 0) | +| `ai.provider.openai-compatible.apiKey` | API-Schlüssel (Umgebungsvariable `OPENAI_COMPATIBLE_API_KEY` hat Vorrang) | + +**Anthropic Claude-Provider** (`ai.provider.active=claude`): + +| Parameter | Beschreibung | +|-----------|--------------| +| `ai.provider.claude.baseUrl` | Basis-URL (optional; Standard: `https://api.anthropic.com`) | +| `ai.provider.claude.model` | Modellname (z. B. `claude-3-5-sonnet-20241022`) | +| `ai.provider.claude.timeoutSeconds` | HTTP-Timeout in Sekunden (ganzzahlig, > 0) | +| `ai.provider.claude.apiKey` | API-Schlüssel (Umgebungsvariable `ANTHROPIC_API_KEY` hat Vorrang) | ### Optionale Parameter -| Parameter | Beschreibung | Standard | -|----------------------|--------------|---------| -| `api.key` | API-Schlüssel (alternativ: Umgebungsvariable `PDF_UMBENENNER_API_KEY`) | – | -| `runtime.lock.file` | Lock-Datei für Startschutz | `pdf-umbenenner.lock` im Arbeitsverzeichnis | -| `log.directory` | Log-Verzeichnis | `./logs/` | -| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | -| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` | +| Parameter | Beschreibung | Standard | +|---------------------|--------------|---------| +| `runtime.lock.file` | Lock-Datei für Startschutz | `pdf-umbenenner.lock` im Arbeitsverzeichnis | +| `log.directory` | Log-Verzeichnis | `./logs/` | +| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | +| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` | ### API-Schlüssel -Der API-Schlüssel kann auf zwei Wegen gesetzt werden: +Pro Provider-Familie existiert eine eigene Umgebungsvariable, die Vorrang vor dem Properties-Wert hat: -1. **Umgebungsvariable `PDF_UMBENENNER_API_KEY`** (empfohlen, hat Vorrang) -2. Property `api.key` in `config/application.properties` +| Provider | Umgebungsvariable | +|---|---| +| `openai-compatible` | `OPENAI_COMPATIBLE_API_KEY` | +| `claude` | `ANTHROPIC_API_KEY` | -Die Umgebungsvariable hat immer Vorrang über die Properties-Datei. +Schlüssel verschiedener Provider-Familien werden niemals vermischt. + +--- + +## Migration älterer Konfigurationsdateien + +Ältere Konfigurationsdateien, die noch die flachen Schlüssel `api.baseUrl`, `api.model`, +`api.timeoutSeconds` und `api.key` verwenden, werden beim ersten Start **automatisch** +in das aktuelle Schema überführt. + +### Was passiert + +1. Die Anwendung erkennt die veraltete Form anhand der flachen `api.*`-Schlüssel. +2. **Vor jeder Änderung** wird eine Sicherungskopie der Originaldatei angelegt: + - Standardfall: `config/application.properties.bak` + - Falls `.bak` bereits existiert: `config/application.properties.bak.1`, `.bak.2`, … + - Bestehende Sicherungen werden **niemals überschrieben**. +3. Die Datei wird in-place in das neue Schema überführt: + - `api.baseUrl` → `ai.provider.openai-compatible.baseUrl` + - `api.model` → `ai.provider.openai-compatible.model` + - `api.timeoutSeconds` → `ai.provider.openai-compatible.timeoutSeconds` + - `api.key` → `ai.provider.openai-compatible.apiKey` + - `ai.provider.active=openai-compatible` wird ergänzt. + - Alle übrigen Schlüssel bleiben unverändert. +4. Die migrierte Datei wird über eine temporäre Datei (`*.tmp`) und atomischen + Move/Rename geschrieben. Das Original wird niemals teilbeschrieben. +5. Die migrierte Datei wird sofort neu eingelesen und validiert. + +### Bei Migrationsfehler + +Schlägt die Validierung der migrierten Datei fehl, bricht die Anwendung mit Exit-Code `1` ab. +Die Sicherungskopie (`.bak`) bleibt in diesem Fall erhalten und enthält die unveränderte +Originaldatei. Die Konfiguration muss dann manuell korrigiert werden. + +### Betreiber-Hinweis + +Die Umgebungsvariable `PDF_UMBENENNER_API_KEY` des Vorgängerstands wird **nicht** automatisch +umbenannt. Falls dieser Wert bislang verwendet wurde, muss er auf `OPENAI_COMPATIBLE_API_KEY` +umgestellt werden. --- diff --git a/docs/workpackages/V1.1 - Abschlussnachweis.md b/docs/workpackages/V1.1 - Abschlussnachweis.md new file mode 100644 index 0000000..483f8d9 --- /dev/null +++ b/docs/workpackages/V1.1 - Abschlussnachweis.md @@ -0,0 +1,149 @@ +# V1.1 – Abschlussnachweis + +## Datum und betroffene Module + +**Datum:** 2026-04-09 + +**Betroffene Module:** + +| Modul | Art der Änderung | +|---|---| +| `pdf-umbenenner-application` | Neue Konfigurationstypen (`MultiProviderConfiguration`, `ProviderConfiguration`, `AiProviderFamily`) | +| `pdf-umbenenner-adapter-out` | Neuer Anthropic-Adapter (`AnthropicClaudeHttpAdapter`), neuer Parser (`MultiProviderConfigurationParser`), neuer Validator (`MultiProviderConfigurationValidator`), Migrator (`LegacyConfigurationMigrator`), Schema-Migration (`ai_provider`-Spalte), aktualisierter OpenAI-Adapter (`OpenAiHttpAdapter`), aktualisierter Properties-Adapter (`PropertiesConfigurationPortAdapter`) | +| `pdf-umbenenner-bootstrap` | Provider-Selektor (`AiProviderSelector`), aktualisierter `BootstrapRunner` (Migration, Provider-Auswahl, Logging) | +| `pdf-umbenenner-adapter-in-cli` | Keine fachliche Änderung | +| `pdf-umbenenner-domain` | Keine Änderung | +| `config/` | Beispiel-Properties-Dateien auf neues Schema aktualisiert | +| `docs/betrieb.md` | Abschnitte KI-Provider-Auswahl und Migration ergänzt | + +--- + +## Pflicht-Testfälle je Arbeitspaket + +### AP-001 – Konfigurations-Schema einführen + +| Testfall | Klasse | Status | +|---|---|---| +| `parsesNewSchemaWithOpenAiCompatibleActive` | `MultiProviderConfigurationTest` | grün | +| `parsesNewSchemaWithClaudeActive` | `MultiProviderConfigurationTest` | grün | +| `claudeBaseUrlDefaultsWhenMissing` | `MultiProviderConfigurationTest` | grün | +| `rejectsMissingActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `rejectsUnknownActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `rejectsMissingMandatoryFieldForActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `acceptsMissingMandatoryFieldForInactiveProvider` | `MultiProviderConfigurationTest` | grün | +| `envVarOverridesPropertiesApiKeyForActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `envVarOnlyResolvesForActiveProvider` | `MultiProviderConfigurationTest` | grün | +| Bestehende Tests bleiben grün | `PropertiesConfigurationPortAdapterTest`, `StartConfigurationValidatorTest` | grün | + +### AP-002 – Legacy-Migration mit `.bak` + +| Testfall | Klasse | Status | +|---|---|---| +| `migratesLegacyFileWithAllFlatKeys` | `LegacyConfigurationMigratorTest` | grün | +| `createsBakBeforeOverwriting` | `LegacyConfigurationMigratorTest` | grün | +| `bakSuffixIsIncrementedIfBakExists` | `LegacyConfigurationMigratorTest` | grün | +| `noOpForAlreadyMigratedFile` | `LegacyConfigurationMigratorTest` | grün | +| `reloadAfterMigrationSucceeds` | `LegacyConfigurationMigratorTest` | grün | +| `migrationFailureKeepsBak` | `LegacyConfigurationMigratorTest` | grün | +| `legacyDetectionRequiresAtLeastOneFlatKey` | `LegacyConfigurationMigratorTest` | grün | +| `legacyValuesEndUpInOpenAiCompatibleNamespace` | `LegacyConfigurationMigratorTest` | grün | +| `unrelatedKeysSurviveUnchanged` | `LegacyConfigurationMigratorTest` | grün | +| `inPlaceWriteIsAtomic` | `LegacyConfigurationMigratorTest` | grün | + +### AP-003 – Bootstrap-Provider-Auswahl und Umstellung des bestehenden OpenAI-Adapters + +| Testfall | Klasse | Status | +|---|---|---| +| `bootstrapWiresOpenAiCompatibleAdapterWhenActive` | `AiProviderSelectorTest` | grün | +| `bootstrapFailsHardWhenActiveProviderUnknown` | `AiProviderSelectorTest` | grün | +| `bootstrapFailsHardWhenSelectedProviderHasNoImplementation` | `AiProviderSelectorTest` | grün | +| `openAiAdapterReadsValuesFromNewNamespace` | `OpenAiHttpAdapterTest` | grün | +| `openAiAdapterBehaviorIsUnchanged` | `OpenAiHttpAdapterTest` | grün | +| `activeProviderIsLoggedAtRunStart` | `BootstrapRunnerTest` | grün | +| `existingDocumentProcessingTestsRemainGreen` | `BatchRunEndToEndTest` | grün | +| `legacyFileEndToEndStillRuns` | `BootstrapRunnerTest` | grün | + +### AP-004 – Persistenz: Provider-Identifikator additiv + +| Testfall | Klasse | Status | +|---|---|---| +| `addsProviderColumnOnFreshDb` | `SqliteAttemptProviderPersistenceTest` | grün | +| `addsProviderColumnOnExistingDbWithoutColumn` | `SqliteAttemptProviderPersistenceTest` | grün | +| `migrationIsIdempotent` | `SqliteAttemptProviderPersistenceTest` | grün | +| `existingRowsKeepNullProvider` | `SqliteAttemptProviderPersistenceTest` | grün | +| `newAttemptsWriteOpenAiCompatibleProvider` | `SqliteAttemptProviderPersistenceTest` | grün | +| `newAttemptsWriteClaudeProvider` | `SqliteAttemptProviderPersistenceTest` | grün | +| `repositoryReadsProviderColumn` | `SqliteAttemptProviderPersistenceTest` | grün | +| `legacyDataReadingDoesNotFail` | `SqliteAttemptProviderPersistenceTest` | grün | +| `existingHistoryTestsRemainGreen` | `SqliteAttemptProviderPersistenceTest` | grün | + +### AP-005 – Nativer Anthropic-Adapter implementieren und verdrahten + +| Testfall | Klasse | Status | +|---|---|---| +| `claudeAdapterBuildsCorrectRequest` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterUsesEnvVarApiKey` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterFallsBackToPropertiesApiKey` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterFailsValidationWhenBothKeysMissing` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterParsesSingleTextBlock` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterConcatenatesMultipleTextBlocks` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterIgnoresNonTextBlocks` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterFailsOnEmptyTextContent` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsHttp401AsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsHttp429AsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsHttp500AsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsTimeoutAsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsUnparseableJsonAsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `bootstrapSelectsClaudeWhenActive` | `AiProviderSelectorTest` | grün | +| `claudeProviderIdentifierLandsInAttemptHistory` | `AnthropicClaudeAdapterIntegrationTest` | grün | +| `existingOpenAiPathRemainsGreen` | alle `OpenAiHttpAdapterTest`-Tests | grün | + +### AP-006 – Regression, Smoke, Doku, Abschlussnachweis + +| Testfall | Klasse | Status | +|---|---|---| +| `smokeBootstrapWithOpenAiCompatibleActive` | `BootstrapSmokeTest` | grün | +| `smokeBootstrapWithClaudeActive` | `BootstrapSmokeTest` | grün | +| `e2eMigrationFromLegacyDemoConfig` | `ProviderIdentifierE2ETest` | grün | +| `regressionExistingOpenAiSuiteGreen` | `ProviderIdentifierE2ETest` | grün | +| `e2eClaudeRunWritesProviderIdentifierToHistory` | `ProviderIdentifierE2ETest` | grün | +| `e2eOpenAiRunWritesProviderIdentifierToHistory` | `ProviderIdentifierE2ETest` | grün | +| `legacyDataFromBeforeV11RemainsReadable` | `ProviderIdentifierE2ETest` | grün | + +--- + +## Belegte Eigenschaften + +| Eigenschaft | Nachweis | +|---|---| +| Zwei Provider-Familien unterstützt | `AiProviderSelectorTest`, `BootstrapSmokeTest` | +| Genau einer aktiv pro Lauf | `MultiProviderConfigurationTest`, `BootstrapSmokeTest` | +| Kein automatischer Fallback | keine Fallback-Logik in `AiProviderSelector` oder Application-Schicht | +| Fachlicher Vertrag (`NamingProposal`) unverändert | `AiResponseParser`, `AiNamingService` unverändert; beide Adapter liefern denselben Domain-Typ | +| Persistenz rückwärtsverträglich | `SqliteAttemptProviderPersistenceTest`, `legacyDataFromBeforeV11RemainsReadable` | +| Migration nachgewiesen | `LegacyConfigurationMigratorTest`, `e2eMigrationFromLegacyDemoConfig` | +| `.bak`-Sicherung nachgewiesen | `LegacyConfigurationMigratorTest.createsBakBeforeOverwriting`, `e2eMigrationFromLegacyDemoConfig` | +| Aktiver Provider wird geloggt | `BootstrapRunnerTest.activeProviderIsLoggedAtRunStart` | +| Keine Architekturbrüche | kein `Application`- oder `Domain`-Code kennt OpenAI- oder Claude-spezifische Typen | +| Keine neuen Bibliotheken | Anthropic-Adapter nutzt Java HTTP Client und `org.json` (beides bereits im Repo etabliert) | + +--- + +## Betreiberaufgabe + +Wer bisher die Umgebungsvariable `PDF_UMBENENNER_API_KEY` oder eine andere eigene Variable für den +OpenAI-kompatiblen API-Schlüssel eingesetzt hat, muss diese auf **`OPENAI_COMPATIBLE_API_KEY`** umstellen. +Die Anwendung akzeptiert nur diese kanonische Umgebungsvariable; ältere proprietäre Namen werden +nicht automatisch ausgewertet. + +--- + +## Build-Ergebnis + +Build-Kommando: + +``` +.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-bootstrap --also-make +``` + +Build-Status: **ERFOLGREICH** — alle Tests grün, Mutationstests in allen Modulen ausgeführt. diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java new file mode 100644 index 0000000..26de81e --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java @@ -0,0 +1,394 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.ai; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Objects; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse; +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; + +/** + * Adapter implementing the native Anthropic Messages API for AI service invocation. + *
+ * This adapter: + *
+ * The adapter sends a POST request to {@code {baseUrl}/v1/messages} with: + *
+ * All errors are mapped to {@link AiInvocationTechnicalFailure} and follow the existing + * transient error semantics. No new error categories are introduced: + *
+ * This value is sufficient for the expected NamingProposal JSON response + * ({@code date}, {@code title}, {@code reasoning}) without requiring a separate + * configuration property. Anthropic's API requires this field to be present. + */ + private static final int MAX_TOKENS = 1024; + + private final HttpClient httpClient; + private final URI apiBaseUrl; + private final String apiModel; + private final String apiKey; + private final int apiTimeoutSeconds; + + // Test-only field to capture the last built JSON body for assertion + private volatile String lastBuiltJsonBody; + + /** + * Creates an adapter from the Claude provider configuration. + *
+ * If {@code config.baseUrl()} is absent or blank, the default Anthropic endpoint + * {@code https://api.anthropic.com} is used. The HTTP client is initialized with + * the configured timeout. + * + * @param config the provider configuration for the Claude family; must not be null + * @throws NullPointerException if config is null + * @throws IllegalArgumentException if the model is missing or blank + */ + public AnthropicClaudeHttpAdapter(ProviderConfiguration config) { + this(config, HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(config.timeoutSeconds())) + .build()); + } + + /** + * Creates an adapter with a custom HTTP client (primarily for testing). + *
+ * This constructor allows tests to inject a mock or configurable HTTP client + * while keeping configuration validation consistent with the production constructor. + *
+ * For testing only: This is package-private to remain internal to the adapter. + * + * @param config the provider configuration; must not be null + * @param httpClient the HTTP client to use; must not be null + * @throws NullPointerException if config or httpClient is null + * @throws IllegalArgumentException if the model is missing or blank + */ + AnthropicClaudeHttpAdapter(ProviderConfiguration config, HttpClient httpClient) { + Objects.requireNonNull(config, "config must not be null"); + Objects.requireNonNull(httpClient, "httpClient must not be null"); + if (config.model() == null || config.model().isBlank()) { + throw new IllegalArgumentException("API model must not be null or empty"); + } + + String baseUrlStr = (config.baseUrl() != null && !config.baseUrl().isBlank()) + ? config.baseUrl() + : DEFAULT_BASE_URL; + + this.apiBaseUrl = URI.create(baseUrlStr); + this.apiModel = config.model(); + this.apiKey = config.apiKey() != null ? config.apiKey() : ""; + this.apiTimeoutSeconds = config.timeoutSeconds(); + this.httpClient = httpClient; + + LOG.debug("AnthropicClaudeHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s", + apiBaseUrl, apiModel, apiTimeoutSeconds); + } + + /** + * Invokes the Anthropic Claude AI service with the given request. + *
+ * Constructs an Anthropic Messages API request from the request representation,
+ * executes it, extracts the text content from the response, and returns either
+ * a successful response or a classified technical failure.
+ *
+ * @param request the AI request with prompt and document text; must not be null
+ * @return an {@link AiInvocationResult} encoding either success (with extracted text)
+ * or a technical failure with classified reason
+ * @throws NullPointerException if request is null
+ */
+ @Override
+ public AiInvocationResult invoke(AiRequestRepresentation request) {
+ Objects.requireNonNull(request, "request must not be null");
+
+ try {
+ HttpRequest httpRequest = buildRequest(request);
+ HttpResponse
+ * Constructs:
+ *
+ * Resolves {@code {apiBaseUrl}/v1/messages}.
+ *
+ * @return the complete endpoint URI
+ */
+ private URI buildEndpointUri() {
+ String endpointPath = apiBaseUrl.getPath().replaceAll("/$", "") + MESSAGES_ENDPOINT;
+ return URI.create(apiBaseUrl.getScheme() + "://" +
+ apiBaseUrl.getHost() +
+ (apiBaseUrl.getPort() > 0 ? ":" + apiBaseUrl.getPort() : "") +
+ endpointPath);
+ }
+
+ /**
+ * Builds the JSON request body for the Anthropic Messages API.
+ *
+ * The body contains:
+ *
+ * Package-private for testing: This method is accessible to tests
+ * in the same package to verify the actual JSON body structure and content.
+ *
+ * @param request the request with prompt and document text
+ * @return JSON string ready to send in HTTP body
+ */
+ String buildJsonRequestBody(AiRequestRepresentation request) {
+ JSONObject body = new JSONObject();
+ body.put("model", apiModel);
+ body.put("max_tokens", MAX_TOKENS);
+
+ // Prompt content goes to the top-level system field (not a role=system message)
+ if (request.promptContent() != null && !request.promptContent().isBlank()) {
+ body.put("system", request.promptContent());
+ }
+
+ JSONObject userMessage = new JSONObject();
+ userMessage.put("role", "user");
+ userMessage.put("content", request.documentText());
+ body.put("messages", new JSONArray().put(userMessage));
+
+ return body.toString();
+ }
+
+ /**
+ * Extracts the text content from a successful (HTTP 200) Anthropic response.
+ *
+ * Concatenates all {@code content} blocks with {@code type=="text"} in order.
+ * Blocks of other types (e.g., tool use) are ignored.
+ * If no {@code text} blocks are present, a technical failure is returned.
+ *
+ * @param request the original request (carried through to the result)
+ * @param responseBody the raw HTTP response body
+ * @return success with the concatenated text, or a technical failure
+ */
+ private AiInvocationResult extractTextFromResponse(AiRequestRepresentation request, String responseBody) {
+ try {
+ JSONObject json = new JSONObject(responseBody);
+ JSONArray contentArray = json.getJSONArray("content");
+
+ StringBuilder textBuilder = new StringBuilder();
+ for (int i = 0; i < contentArray.length(); i++) {
+ JSONObject block = contentArray.getJSONObject(i);
+ if ("text".equals(block.optString("type"))) {
+ textBuilder.append(block.getString("text"));
+ }
+ }
+
+ String extractedText = textBuilder.toString();
+ if (extractedText.isEmpty()) {
+ LOG.warn("Claude AI response contained no text-type content blocks");
+ return new AiInvocationTechnicalFailure(request, "NO_TEXT_CONTENT",
+ "Anthropic response contained no text-type content blocks");
+ }
+
+ return new AiInvocationSuccess(request, new AiRawResponse(extractedText));
+ } catch (JSONException e) {
+ LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
+ return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
+ "Anthropic response body is not valid JSON: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Package-private accessor for the last constructed JSON body.
+ *
+ * For testing only: Allows tests to verify the actual
+ * JSON body sent in HTTP requests without exposing the BodyPublisher internals.
+ *
+ * @return the last JSON body string constructed by {@link #buildRequest(AiRequestRepresentation)},
+ * or null if no request has been built yet
+ */
+ String getLastBuiltJsonBodyForTesting() {
+ return lastBuiltJsonBody;
+ }
+
+ /**
+ * Executes the HTTP request and returns the response.
+ *
+ * @param httpRequest the HTTP request to execute
+ * @return the HTTP response with status code and body
+ * @throws java.net.http.HttpTimeoutException if the request times out
+ * @throws java.net.ConnectException if connection fails
+ * @throws java.io.IOException on other IO errors
+ * @throws InterruptedException if the request is interrupted
+ */
+ private HttpResponse
* Configuration:
*
* HTTP request structure:
- * The adapter sends a POST request to the endpoint {@code {apiBaseUrl}/v1/chat/completions}
+ * The adapter sends a POST request to the endpoint {@code {baseUrl}/v1/chat/completions}
* with:
*
- * The adapter initializes an HTTP client with the configured timeout and creates
- * the endpoint URL from the base URL. Configuration values are validated for
- * null/empty during initialization.
+ * The adapter initializes an HTTP client with the configured timeout and parses
+ * the endpoint URI from the configured base URL string.
*
- * @param config the startup configuration containing API settings; must not be null
+ * @param config the provider configuration for the OpenAI-compatible family; must not be null
* @throws NullPointerException if config is null
- * @throws IllegalArgumentException if API base URL or model is missing/empty
+ * @throws IllegalArgumentException if the base URL or model is missing/blank
*/
- public OpenAiHttpAdapter(StartConfiguration config) {
+ public OpenAiHttpAdapter(ProviderConfiguration config) {
this(config, HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(config.apiTimeoutSeconds()))
+ .connectTimeout(Duration.ofSeconds(config.timeoutSeconds()))
.build());
}
@@ -130,25 +129,25 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
*
* For testing only: This is package-private to remain internal to the adapter.
*
- * @param config the startup configuration containing API settings; must not be null
+ * @param config the provider configuration; must not be null
* @param httpClient the HTTP client to use; must not be null
* @throws NullPointerException if config or httpClient is null
- * @throws IllegalArgumentException if API base URL or model is missing/empty
+ * @throws IllegalArgumentException if the base URL or model is missing/blank
*/
- OpenAiHttpAdapter(StartConfiguration config, HttpClient httpClient) {
+ OpenAiHttpAdapter(ProviderConfiguration config, HttpClient httpClient) {
Objects.requireNonNull(config, "config must not be null");
Objects.requireNonNull(httpClient, "httpClient must not be null");
- if (config.apiBaseUrl() == null) {
+ if (config.baseUrl() == null || config.baseUrl().isBlank()) {
throw new IllegalArgumentException("API base URL must not be null");
}
- if (config.apiModel() == null || config.apiModel().isBlank()) {
+ if (config.model() == null || config.model().isBlank()) {
throw new IllegalArgumentException("API model must not be null or empty");
}
- this.apiBaseUrl = config.apiBaseUrl();
- this.apiModel = config.apiModel();
+ this.apiBaseUrl = URI.create(config.baseUrl());
+ this.apiModel = config.model();
this.apiKey = config.apiKey() != null ? config.apiKey() : "";
- this.apiTimeoutSeconds = config.apiTimeoutSeconds();
+ this.apiTimeoutSeconds = config.timeoutSeconds();
this.httpClient = httpClient;
LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
@@ -229,7 +228,7 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
*
@@ -156,13 +157,13 @@ public class StartConfigurationValidator {
validateSourceFolder(config.sourceFolder(), errors);
validateTargetFolder(config.targetFolder(), errors);
validateSqliteFile(config.sqliteFile(), errors);
- validateApiBaseUrl(config.apiBaseUrl(), errors);
- validateApiModel(config.apiModel(), errors);
validatePromptTemplateFile(config.promptTemplateFile(), errors);
+ if (config.multiProviderConfiguration() == null) {
+ errors.add("- ai provider configuration: must not be null");
+ }
}
private void validateNumericConstraints(StartConfiguration config, List
+ * Intended for testing, where a controlled (e.g. always-failing) validator can be supplied
+ * to verify that the {@code .bak} backup is preserved when post-migration validation fails.
+ *
+ * @param parser parser used to re-read the migrated file; must not be {@code null}
+ * @param validator validator used to verify the migrated file; must not be {@code null}
+ */
+ public LegacyConfigurationMigrator(MultiProviderConfigurationParser parser,
+ MultiProviderConfigurationValidator validator) {
+ this.parser = parser;
+ this.validator = validator;
+ }
+
+ /**
+ * Migrates the configuration file at {@code configFilePath} if it is in legacy form.
+ *
+ * If the file does not contain legacy flat keys or already contains
+ * {@code ai.provider.active}, this method returns immediately without any I/O side effect.
+ *
+ * @param configFilePath path to the configuration file; must exist and be readable
+ * @throws ConfigurationLoadingException if the file cannot be read, the backup cannot be
+ * created, the migrated file cannot be written, or post-migration validation fails
+ */
+ public void migrateIfLegacy(Path configFilePath) {
+ String originalContent = readFile(configFilePath);
+ Properties props = parsePropertiesFromContent(originalContent);
+
+ if (!isLegacyForm(props)) {
+ return;
+ }
+
+ LOG.info("Legacy configuration format detected. Migrating: {}", configFilePath);
+
+ createBakBackup(configFilePath, originalContent);
+
+ String migratedContent = generateMigratedContent(originalContent);
+ writeAtomically(configFilePath, migratedContent);
+
+ LOG.info("Configuration file migrated to multi-provider schema: {}", configFilePath);
+
+ validateMigratedFile(configFilePath);
+ }
+
+ /**
+ * Returns {@code true} if the given properties are in legacy form.
+ *
+ * A properties set is considered legacy when it contains at least one of the four
+ * flat legacy keys and does not already contain {@code ai.provider.active}.
+ *
+ * @param props the parsed properties to inspect; must not be {@code null}
+ * @return {@code true} if migration is required, {@code false} otherwise
+ */
+ boolean isLegacyForm(Properties props) {
+ boolean hasLegacyKey = props.containsKey(LEGACY_BASE_URL)
+ || props.containsKey(LEGACY_MODEL)
+ || props.containsKey(LEGACY_TIMEOUT)
+ || props.containsKey(LEGACY_API_KEY);
+ boolean hasNewKey = props.containsKey(MultiProviderConfigurationParser.PROP_ACTIVE_PROVIDER);
+ return hasLegacyKey && !hasNewKey;
+ }
+
+ /**
+ * Creates a backup of the original file before overwriting it.
+ *
+ * If {@code
+ * Each line is inspected: lines that define a legacy key are rewritten with the
+ * corresponding new namespaced key; all other lines (comments, blank lines, other keys)
+ * pass through unchanged. After all original lines, a {@code ai.provider.active} entry
+ * and a commented Claude-provider placeholder block are appended.
+ *
+ * @param originalContent the raw original file content; must not be {@code null}
+ * @return the migrated content ready to be written to disk
+ */
+ String generateMigratedContent(String originalContent) {
+ String[] lines = originalContent.split("\\r?\\n", -1);
+ StringBuilder sb = new StringBuilder();
+ for (String line : lines) {
+ sb.append(transformLine(line)).append("\n");
+ }
+ sb.append("\n");
+ sb.append("# Aktiver KI-Provider: openai-compatible oder claude\n");
+ sb.append("ai.provider.active=openai-compatible\n");
+ sb.append("\n");
+ sb.append("# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude)\n");
+ sb.append("# ai.provider.claude.model=\n");
+ sb.append("# ai.provider.claude.timeoutSeconds=\n");
+ sb.append("# ai.provider.claude.apiKey=\n");
+ return sb.toString();
+ }
+
+ /**
+ * Transforms a single properties-file line, replacing a legacy key with its new equivalent.
+ *
+ * Comment lines, blank lines, and lines defining keys other than the four legacy keys
+ * are returned unchanged.
+ */
+ private String transformLine(String line) {
+ for (String[] mapping : LEGACY_KEY_MAPPINGS) {
+ String legacyKey = mapping[0];
+ String newKey = mapping[1];
+ if (lineDefinesKey(line, legacyKey)) {
+ int keyStart = line.indexOf(legacyKey);
+ return line.substring(0, keyStart) + newKey + line.substring(keyStart + legacyKey.length());
+ }
+ }
+ return line;
+ }
+
+ /**
+ * Returns {@code true} when {@code line} defines the given {@code key}.
+ *
+ * A line defines a key if — after stripping any leading whitespace — it starts with
+ * the exact key string followed by {@code =}, {@code :}, whitespace, or end-of-string.
+ * Comment-introducing characters ({@code #} or {@code !}) cause an immediate {@code false}.
+ */
+ private boolean lineDefinesKey(String line, String key) {
+ String trimmed = line.stripLeading();
+ if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) {
+ return false;
+ }
+ if (!trimmed.startsWith(key)) {
+ return false;
+ }
+ if (trimmed.length() == key.length()) {
+ return true;
+ }
+ char next = trimmed.charAt(key.length());
+ return next == '=' || next == ':' || Character.isWhitespace(next);
+ }
+
+ /**
+ * Writes {@code content} to {@code target} via a temporary file and an atomic rename.
+ *
+ * The temporary file is created as {@code
+ * A parse or validation failure is treated as a hard startup error. The {@code .bak} backup
+ * created before migration is preserved in this case.
+ */
+ private void validateMigratedFile(Path configFilePath) {
+ String content = readFile(configFilePath);
+ Properties props = parsePropertiesFromContent(content);
+
+ MultiProviderConfiguration config;
+ try {
+ config = parser.parse(props);
+ } catch (ConfigurationLoadingException e) {
+ throw new ConfigurationLoadingException(
+ "Migrated configuration failed to parse: " + e.getMessage(), e);
+ }
+
+ try {
+ validator.validate(config);
+ } catch (InvalidStartConfigurationException e) {
+ throw new ConfigurationLoadingException(
+ "Migrated configuration failed validation (backup preserved): " + e.getMessage(), e);
+ }
+ }
+
+ private String readFile(Path path) {
+ try {
+ return Files.readString(path, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new ConfigurationLoadingException("Failed to read file: " + path, e);
+ }
+ }
+
+ private void writeFile(Path path, String content) {
+ try {
+ Files.writeString(path, content, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new ConfigurationLoadingException("Failed to write file: " + path, e);
+ }
+ }
+
+ private Properties parsePropertiesFromContent(String content) {
+ Properties props = new Properties();
+ try {
+ props.load(new StringReader(content));
+ } catch (IOException e) {
+ throw new ConfigurationLoadingException("Failed to parse properties content", e);
+ }
+ return props;
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java
new file mode 100644
index 0000000..2ca104d
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java
@@ -0,0 +1,203 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
+
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
+
+import java.util.Properties;
+import java.util.function.Function;
+
+/**
+ * Parses the multi-provider configuration schema from a {@link Properties} object.
+ *
+ * Recognises the following property keys:
+ * The returned {@link MultiProviderConfiguration} is not yet validated. Use
+ * {@link MultiProviderConfigurationValidator} after parsing.
+ */
+public class MultiProviderConfigurationParser {
+
+ /** Property key selecting the active provider family. */
+ static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
+
+ static final String PROP_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
+ static final String PROP_OPENAI_MODEL = "ai.provider.openai-compatible.model";
+ static final String PROP_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
+ static final String PROP_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
+
+ static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
+ static final String PROP_CLAUDE_MODEL = "ai.provider.claude.model";
+ static final String PROP_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
+ static final String PROP_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
+
+ /** Environment variable for the OpenAI-compatible provider API key. */
+ static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
+
+ /** Environment variable for the Anthropic Claude provider API key. */
+ static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
+
+ /** Default base URL for the Anthropic Claude provider when not explicitly configured. */
+ static final String CLAUDE_DEFAULT_BASE_URL = "https://api.anthropic.com";
+
+ private final Function
+ * This constructor is intended for testing to allow deterministic control over
+ * environment variable values without modifying the real process environment.
+ *
+ * @param environmentLookup a function that maps environment variable names to their values;
+ * must not be {@code null}
+ */
+ public MultiProviderConfigurationParser(Function
+ * The Claude default base URL ({@code https://api.anthropic.com}) is applied when
+ * {@code ai.provider.claude.baseUrl} is absent. API keys are resolved with environment
+ * variable precedence. The resulting configuration is not yet validated; call
+ * {@link MultiProviderConfigurationValidator#validate(MultiProviderConfiguration)} afterward.
+ *
+ * @param props the properties to parse; must not be {@code null}
+ * @return the parsed (but not yet validated) multi-provider configuration
+ * @throws ConfigurationLoadingException if {@code ai.provider.active} is absent, blank,
+ * or holds an unrecognised value, or if any present timeout property is not a
+ * valid integer
+ */
+ public MultiProviderConfiguration parse(Properties props) {
+ AiProviderFamily activeFamily = parseActiveProvider(props);
+ ProviderConfiguration openAiConfig = parseOpenAiCompatibleConfig(props);
+ ProviderConfiguration claudeConfig = parseClaudeConfig(props);
+ return new MultiProviderConfiguration(activeFamily, openAiConfig, claudeConfig);
+ }
+
+ private AiProviderFamily parseActiveProvider(Properties props) {
+ String raw = props.getProperty(PROP_ACTIVE_PROVIDER);
+ if (raw == null || raw.isBlank()) {
+ throw new ConfigurationLoadingException(
+ "Required property missing or blank: " + PROP_ACTIVE_PROVIDER
+ + ". Valid values: openai-compatible, claude");
+ }
+ String trimmed = raw.trim();
+ return AiProviderFamily.fromIdentifier(trimmed).orElseThrow(() ->
+ new ConfigurationLoadingException(
+ "Unknown provider identifier for " + PROP_ACTIVE_PROVIDER + ": '" + trimmed
+ + "'. Valid values: openai-compatible, claude"));
+ }
+
+ private ProviderConfiguration parseOpenAiCompatibleConfig(Properties props) {
+ String model = getOptionalString(props, PROP_OPENAI_MODEL);
+ int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT);
+ String baseUrl = getOptionalString(props, PROP_OPENAI_BASE_URL);
+ String apiKey = resolveApiKey(props, PROP_OPENAI_API_KEY, ENV_OPENAI_API_KEY);
+ return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
+ }
+
+ private ProviderConfiguration parseClaudeConfig(Properties props) {
+ String model = getOptionalString(props, PROP_CLAUDE_MODEL);
+ int timeout = parseTimeoutSeconds(props, PROP_CLAUDE_TIMEOUT);
+ String baseUrl = getStringOrDefault(props, PROP_CLAUDE_BASE_URL, CLAUDE_DEFAULT_BASE_URL);
+ String apiKey = resolveApiKey(props, PROP_CLAUDE_API_KEY, ENV_CLAUDE_API_KEY);
+ return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
+ }
+
+ /**
+ * Returns the trimmed property value, or {@code null} if absent or blank.
+ */
+ private String getOptionalString(Properties props, String key) {
+ String value = props.getProperty(key);
+ return (value == null || value.isBlank()) ? null : value.trim();
+ }
+
+ /**
+ * Returns the trimmed property value, or the {@code defaultValue} if absent or blank.
+ */
+ private String getStringOrDefault(Properties props, String key, String defaultValue) {
+ String value = props.getProperty(key);
+ return (value == null || value.isBlank()) ? defaultValue : value.trim();
+ }
+
+ /**
+ * Parses a timeout property as a positive integer.
+ *
+ * Returns {@code 0} when the property is absent or blank (indicating "not configured").
+ * Throws {@link ConfigurationLoadingException} when the property is present but not
+ * parseable as an integer.
+ */
+ private int parseTimeoutSeconds(Properties props, String key) {
+ String value = props.getProperty(key);
+ if (value == null || value.isBlank()) {
+ return 0;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ throw new ConfigurationLoadingException(
+ "Invalid integer value for property " + key + ": '" + value.trim() + "'", e);
+ }
+ }
+
+ /**
+ * Resolves the effective API key for a provider family.
+ *
+ * The environment variable value takes precedence over the properties value.
+ * If the environment variable is absent or blank, the properties value is used.
+ * If both are absent or blank, an empty string is returned (the validator will
+ * reject this for the active provider).
+ *
+ * @param props the configuration properties
+ * @param propertyKey the property key for the API key of this provider family
+ * @param envVarName the environment variable name for this provider family
+ * @return the resolved API key; never {@code null}, but may be blank
+ */
+ private String resolveApiKey(Properties props, String propertyKey, String envVarName) {
+ String envValue = environmentLookup.apply(envVarName);
+ if (envValue != null && !envValue.isBlank()) {
+ return envValue.trim();
+ }
+ String propsValue = props.getProperty(propertyKey);
+ return (propsValue != null) ? propsValue.trim() : "";
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java
new file mode 100644
index 0000000..6e499a4
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java
@@ -0,0 +1,106 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
+
+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.MultiProviderConfiguration;
+import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Validates a {@link MultiProviderConfiguration} before the application run begins.
+ *
+ * Enforces all requirements for the active provider:
+ *
+ * Validation errors are aggregated and reported together in a single
+ * {@link InvalidStartConfigurationException}.
+ */
+public class MultiProviderConfigurationValidator {
+
+ /**
+ * Validates the given multi-provider configuration.
+ *
+ * Only the active provider's required fields are validated. The inactive provider's
+ * configuration may be incomplete.
+ *
+ * @param config the configuration to validate; must not be {@code null}
+ * @throws InvalidStartConfigurationException if any validation rule fails, with an aggregated
+ * message listing all problems found
+ */
+ public void validate(MultiProviderConfiguration config) {
+ List
+ * The OpenAI-compatible family requires an explicit base URL.
+ * The Claude family always has a default ({@code https://api.anthropic.com}) applied by the
+ * parser, so this check is a safety net rather than a primary enforcement mechanism.
+ */
+ private void validateBaseUrl(AiProviderFamily family, ProviderConfiguration config,
+ String providerLabel, List
- * Loads configuration from config/application.properties as the primary source.
- * For sensitive values, environment variables take precedence: if the environment variable
- * {@code PDF_UMBENENNER_API_KEY} is set, it overrides the {@code api.key} property from the file.
- * This allows credentials to be managed securely without storing them in the configuration file.
+ * Loads configuration from {@code config/application.properties} as the primary source.
+ * The multi-provider AI configuration is parsed via {@link MultiProviderConfigurationParser}
+ * and validated via {@link MultiProviderConfigurationValidator}. Environment variables
+ * for API keys are resolved by the parser with provider-specific precedence rules:
+ * {@code OPENAI_COMPATIBLE_API_KEY} for the OpenAI-compatible family and
+ * {@code ANTHROPIC_API_KEY} for the Anthropic Claude family.
*/
public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
private static final Logger LOG = LogManager.getLogger(PropertiesConfigurationPortAdapter.class);
private static final String DEFAULT_CONFIG_FILE_PATH = "config/application.properties";
- private static final String API_KEY_ENV_VAR = "PDF_UMBENENNER_API_KEY";
private final Function
+ * Uses {@link MultiProviderConfigurationParser} for parsing and
+ * {@link MultiProviderConfigurationValidator} for validation. Throws on any
+ * configuration error before returning.
+ */
+ private MultiProviderConfiguration parseAndValidateProviders(Properties props) {
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(environmentLookup);
+ MultiProviderConfiguration config = parser.parse(props);
+ new MultiProviderConfigurationValidator().validate(config);
+ return config;
}
- private StartConfiguration buildStartConfiguration(Properties props, String apiKey) {
- boolean logAiSensitive = parseAiContentSensitivity(props);
+ private StartConfiguration buildStartConfiguration(Properties props,
+ MultiProviderConfiguration multiProviderConfig,
+ boolean logAiSensitive) {
return new StartConfiguration(
Paths.get(getRequiredProperty(props, "source.folder")),
Paths.get(getRequiredProperty(props, "target.folder")),
Paths.get(getRequiredProperty(props, "sqlite.file")),
- parseUri(getRequiredProperty(props, "api.baseUrl")),
- getRequiredProperty(props, "api.model"),
- parseInt(getRequiredProperty(props, "api.timeoutSeconds")),
+ multiProviderConfig,
parseInt(getRequiredProperty(props, "max.retries.transient")),
parseInt(getRequiredProperty(props, "max.pages")),
parseInt(getRequiredProperty(props, "max.text.characters")),
@@ -123,19 +130,15 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
Paths.get(getOptionalProperty(props, "runtime.lock.file", "")),
Paths.get(getOptionalProperty(props, "log.directory", "")),
getOptionalProperty(props, "log.level", "INFO"),
- apiKey,
logAiSensitive
);
}
- private String getApiKey(Properties props) {
- String envApiKey = environmentLookup.apply(API_KEY_ENV_VAR);
- if (envApiKey != null && !envApiKey.isBlank()) {
- LOG.info("Using API key from environment variable {}", API_KEY_ENV_VAR);
- return envApiKey;
- }
- String propsApiKey = props.getProperty("api.key");
- return propsApiKey != null ? propsApiKey : "";
+ private String escapeBackslashes(String content) {
+ // Escape backslashes to prevent Java Properties from interpreting them as escape sequences.
+ // This is needed because Windows paths use backslashes (e.g., C:\temp\...)
+ // and Java Properties interprets \t as tab, \n as newline, etc.
+ return content.replace("\\", "\\\\");
}
private String getRequiredProperty(Properties props, String key) {
@@ -169,14 +172,6 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
}
}
- private URI parseUri(String value) {
- try {
- return new URI(value.trim());
- } catch (URISyntaxException e) {
- throw new ConfigurationLoadingException("Invalid URI value for property: " + value, e);
- }
- }
-
/**
* Parses the {@code log.ai.sensitive} configuration property with strict validation.
*
@@ -212,4 +207,4 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
+ "Default is 'false' (sensitive content not logged).");
}
}
-}
\ No newline at end of file
+}
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java
index 2c767b0..3e24f18 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java
@@ -31,9 +31,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* including all AI traceability fields added during schema evolution.
*
* Schema compatibility: This adapter writes all columns including
- * the AI traceability columns. When reading rows that were written before schema
- * evolution, those columns contain {@code NULL} and are mapped to {@code null}
- * in the Java record.
+ * the AI traceability columns and the provider-identifier column ({@code ai_provider}).
+ * When reading rows that were written before schema evolution, those columns contain
+ * {@code NULL} and are mapped to {@code null} in the Java record.
*
* Architecture boundary: All JDBC and SQLite details are strictly
* confined to this class. No JDBC types appear in the port interface or in any
@@ -129,6 +129,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
failure_class,
failure_message,
retryable,
+ ai_provider,
model_name,
prompt_identifier,
processed_page_count,
@@ -139,7 +140,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
date_source,
validated_title,
final_target_file_name
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
@@ -157,19 +158,20 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
setNullableString(statement, 7, attempt.failureClass());
setNullableString(statement, 8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable());
- // AI traceability fields
- setNullableString(statement, 10, attempt.modelName());
- setNullableString(statement, 11, attempt.promptIdentifier());
- setNullableInteger(statement, 12, attempt.processedPageCount());
- setNullableInteger(statement, 13, attempt.sentCharacterCount());
- setNullableString(statement, 14, attempt.aiRawResponse());
- setNullableString(statement, 15, attempt.aiReasoning());
- setNullableString(statement, 16,
- attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
+ // AI provider identifier and AI traceability fields
+ setNullableString(statement, 10, attempt.aiProvider());
+ setNullableString(statement, 11, attempt.modelName());
+ setNullableString(statement, 12, attempt.promptIdentifier());
+ setNullableInteger(statement, 13, attempt.processedPageCount());
+ setNullableInteger(statement, 14, attempt.sentCharacterCount());
+ setNullableString(statement, 15, attempt.aiRawResponse());
+ setNullableString(statement, 16, attempt.aiReasoning());
setNullableString(statement, 17,
+ attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
+ setNullableString(statement, 18,
attempt.dateSource() != null ? attempt.dateSource().name() : null);
- setNullableString(statement, 18, attempt.validatedTitle());
- setNullableString(statement, 19, attempt.finalTargetFileName());
+ setNullableString(statement, 19, attempt.validatedTitle());
+ setNullableString(statement, 20, attempt.finalTargetFileName());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -204,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
- model_name, prompt_identifier, processed_page_count, sent_character_count,
+ ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
@@ -255,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
- model_name, prompt_identifier, processed_page_count, sent_character_count,
+ ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
@@ -312,6 +314,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable"),
+ rs.getString("ai_provider"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java
index 3c4d73e..f6bcb71 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java
@@ -41,6 +41,9 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
*
+ * {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the
+ * correct sentinel for attempts recorded before provider tracking was introduced.
*/
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"},
@@ -162,6 +168,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
{"date_source", "TEXT"},
{"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"},
+ {"ai_provider", "TEXT"},
};
// -------------------------------------------------------------------------
@@ -229,7 +236,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
*
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java
new file mode 100644
index 0000000..d9695d8
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java
@@ -0,0 +1,211 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.ai;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
+import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
+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.ProviderConfiguration;
+import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
+import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
+import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
+import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
+import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
+import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
+
+/**
+ * Integration test verifying that the Anthropic Claude adapter integrates correctly
+ * with the full batch processing pipeline and that the provider identifier
+ * {@code "claude"} is persisted in the processing attempt history.
+ *
+ * Uses a mocked HTTP client to simulate the Anthropic API without real network calls.
+ * All other adapters (SQLite, filesystem, PDF extraction, fingerprinting) are real
+ * production implementations.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("AnthropicClaudeAdapter integration")
+class AnthropicClaudeAdapterIntegrationTest {
+
+ /**
+ * Pflicht-Testfall 15: claudeProviderIdentifierLandsInAttemptHistory
+ *
+ * Verifies the end-to-end integration: the Claude adapter with a mocked HTTP layer
+ * is wired into the batch pipeline, and after a successful run, the processing attempt
+ * record contains {@code ai_provider='claude'}.
+ */
+ @Test
+ @DisplayName("claudeProviderIdentifierLandsInAttemptHistory: ai_provider=claude in attempt history after successful run")
+ @SuppressWarnings("unchecked")
+ void claudeProviderIdentifierLandsInAttemptHistory(@TempDir Path tempDir) throws Exception {
+ // --- Infrastructure setup ---
+ Path sourceFolder = Files.createDirectories(tempDir.resolve("source"));
+ Path targetFolder = Files.createDirectories(tempDir.resolve("target"));
+ Path promptFile = tempDir.resolve("prompt.txt");
+ Files.writeString(promptFile, "Analysiere das Dokument und liefere JSON.");
+
+ String jdbcUrl = "jdbc:sqlite:" + tempDir.resolve("test.db")
+ .toAbsolutePath().toString().replace('\\', '/');
+ new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
+
+ // --- Create a searchable PDF in the source folder ---
+ Path pdfPath = sourceFolder.resolve("testdokument.pdf");
+ createSearchablePdf(pdfPath, "Testinhalt Rechnung Datum 15.01.2024 Betrag 99 EUR");
+
+ // --- Compute fingerprint for later verification ---
+ Sha256FingerprintAdapter fingerprintAdapter = new Sha256FingerprintAdapter();
+ SourceDocumentCandidate candidate = new SourceDocumentCandidate(
+ pdfPath.getFileName().toString(), 0L,
+ new SourceDocumentLocator(pdfPath.toAbsolutePath().toString()));
+ DocumentFingerprint fingerprint = switch (fingerprintAdapter.computeFingerprint(candidate)) {
+ case FingerprintSuccess s -> s.fingerprint();
+ default -> throw new IllegalStateException("Fingerprint computation failed");
+ };
+
+ // --- Mock the HTTP client for the Claude adapter ---
+ HttpClient mockHttpClient = mock(HttpClient.class);
+ // Build a valid Anthropic response with the NamingProposal JSON as text content
+ String namingProposalJson =
+ "{\\\"date\\\":\\\"2024-01-15\\\",\\\"title\\\":\\\"Testrechnung\\\","
+ + "\\\"reasoning\\\":\\\"Rechnung vom 15.01.2024\\\"}";
+ String anthropicResponseBody = "{"
+ + "\"id\":\"msg_integration_test\","
+ + "\"type\":\"message\","
+ + "\"role\":\"assistant\","
+ + "\"content\":[{\"type\":\"text\",\"text\":\"" + namingProposalJson + "\"}],"
+ + "\"stop_reason\":\"end_turn\""
+ + "}";
+
+ HttpResponse
+ * Tests inject a mock {@link HttpClient} via the package-private constructor
+ * to exercise the adapter path without requiring network access.
+ * Configuration is supplied via {@link ProviderConfiguration}.
+ *
+ * Covered scenarios:
+ *
+ * This confirms that the adapter is protected by startup validation (from AP-001)
+ * and will never be constructed with a truly missing API key in production.
+ */
+ @Test
+ @DisplayName("claudeAdapterFailsValidationWhenBothKeysMissing: validator rejects empty API key for Claude")
+ void claudeAdapterFailsValidationWhenBothKeysMissing() {
+ // Simulate both env var and properties key being absent (empty resolved key)
+ ProviderConfiguration claudeConfigWithoutKey = new ProviderConfiguration(
+ API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, "");
+ ProviderConfiguration inactiveOpenAiConfig = new ProviderConfiguration(
+ "unused-model", 0, null, null);
+ MultiProviderConfiguration config = new MultiProviderConfiguration(
+ AiProviderFamily.CLAUDE, inactiveOpenAiConfig, claudeConfigWithoutKey);
+
+ MultiProviderConfigurationValidator validator = new MultiProviderConfigurationValidator();
+
+ assertThatThrownBy(() -> validator.validate(config))
+ .as("Validator must reject Claude configuration with empty API key")
+ .isInstanceOf(InvalidStartConfigurationException.class);
+ }
+
+ // =========================================================================
+ // Pflicht-Testfall 5: claudeAdapterParsesSingleTextBlock
+ // =========================================================================
+
+ /**
+ * Verifies that a response with a single text block is correctly extracted.
+ */
+ @Test
+ @DisplayName("claudeAdapterParsesSingleTextBlock: single text block becomes raw response")
+ void claudeAdapterParsesSingleTextBlock() throws Exception {
+ String blockText = "{\"date\":\"2024-01-15\",\"title\":\"Rechnung\",\"reasoning\":\"Test\"}";
+ String responseBody = buildAnthropicSuccessResponse(blockText);
+ HttpResponse
* Coverage goals:
*
- * This helper method works around Mockito's type variance issues with generics
- * by creating the mock with proper type handling. If body is null, the body()
- * method is not stubbed to avoid unnecessary stubs.
- *
- * @param statusCode the HTTP status code
- * @param body the response body (null to skip body stubbing)
- * @return a mock HttpResponse configured with the given status and body
*/
@SuppressWarnings("unchecked")
private HttpResponse
- * These tests verify the four critical paths for source folder validation without
- * relying on platform-dependent filesystem permissions or the actual FS state.
- */
-
@Test
void validate_failsWhenSourceFolderDoesNotExist_mocked() throws Exception {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
@@ -852,9 +655,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("nonexistent"),
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -862,11 +663,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
- "test-api-key",
false
);
- // Mock: always return "does not exist" error for any path
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: path does not exist: " + path;
@@ -889,9 +688,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("somepath"),
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -899,11 +696,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
- "test-api-key",
false
);
- // Mock: simulate path exists but is not a directory
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: path is not a directory: " + path;
@@ -926,9 +721,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("somepath"),
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -936,12 +729,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
- "test-api-key",
false
);
- // Mock: simulate path exists, is directory, but is not readable
- // This is the critical case that is hard to test on actual FS
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: directory is not readable: " + path;
@@ -965,9 +755,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -975,11 +763,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
- "test-api-key",
false
);
- // Mock: all checks pass (return null)
StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null;
StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker);
@@ -988,24 +774,19 @@ class StartConfigurationValidatorTest {
"Validation should succeed when source folder checker returns null");
}
- // Neue Tests zur Verbesserung der Abdeckung
-
@Test
void validate_failsWhenSqliteFileHasNoParent() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
- // Ein Pfad ohne Parent (z.B. einfacher Dateiname)
Path sqliteFileWithoutParent = Path.of("db.sqlite");
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFileWithoutParent,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -1013,7 +794,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
- "test-api-key",
false
);
@@ -1029,8 +809,7 @@ class StartConfigurationValidatorTest {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- // Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen
+
Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
Path sqliteFileWithFileAsParent = parentFile.resolve("db.sqlite");
@@ -1038,9 +817,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFileWithFileAsParent,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -1048,7 +825,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
- "test-api-key",
false
);
@@ -1059,70 +835,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("sqlite.file: parent is not a directory"));
}
- @Test
- void validate_apiModelBlankString() throws Exception {
- Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
- Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
- Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
- Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- StartConfiguration config = new StartConfiguration(
- sourceFolder,
- targetFolder,
- sqliteFile,
- URI.create("https://api.example.com"),
- " ", // Blank string
- 30,
- 3,
- 100,
- 50000,
- promptTemplateFile,
- null,
- null,
- "INFO",
- "test-api-key",
- false
- );
-
- InvalidStartConfigurationException exception = assertThrows(
- InvalidStartConfigurationException.class,
- () -> validator.validate(config)
- );
- assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
- }
-
- @Test
- void validate_apiModelEmptyString() throws Exception {
- Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
- Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
- Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
- Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- StartConfiguration config = new StartConfiguration(
- sourceFolder,
- targetFolder,
- sqliteFile,
- URI.create("https://api.example.com"),
- "", // Empty string
- 30,
- 3,
- 100,
- 50000,
- promptTemplateFile,
- null,
- null,
- "INFO",
- "test-api-key",
- false
- );
-
- InvalidStartConfigurationException exception = assertThrows(
- InvalidStartConfigurationException.class,
- () -> validator.validate(config)
- );
- assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
- }
-
@Test
void validate_runtimeLockFileParentDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -1134,17 +846,14 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
promptTemplateFile,
- tempDir.resolve("nonexistent/lock.lock"), // Lock file mit nicht existierendem Parent
+ tempDir.resolve("nonexistent/lock.lock"),
null,
"INFO",
- "test-api-key",
false
);
@@ -1161,8 +870,7 @@ class StartConfigurationValidatorTest {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- // Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen
+
Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
Path lockFileWithFileAsParent = parentFile.resolve("lock.lock");
@@ -1170,17 +878,14 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
promptTemplateFile,
- lockFileWithFileAsParent, // Lock file mit Datei als Parent
+ lockFileWithFileAsParent,
null,
"INFO",
- "test-api-key",
false
);
@@ -1197,25 +902,21 @@ class StartConfigurationValidatorTest {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- // Erstelle eine Datei, die als Log-Verzeichnis verwendet wird
+
Path logFileInsteadOfDirectory = Files.createFile(tempDir.resolve("logfile.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
promptTemplateFile,
null,
- logFileInsteadOfDirectory, // Datei statt Verzeichnis
+ logFileInsteadOfDirectory,
"INFO",
- "test-api-key",
false
);
@@ -1225,66 +926,4 @@ class StartConfigurationValidatorTest {
);
assertTrue(exception.getMessage().contains("log.directory: exists but is not a directory"));
}
-
- @Test
- void validate_apiBaseUrlHttpScheme() throws Exception {
- Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
- Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
- Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
- Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- StartConfiguration config = new StartConfiguration(
- sourceFolder,
- targetFolder,
- sqliteFile,
- URI.create("http://api.example.com"), // HTTP statt HTTPS
- "gpt-4",
- 30,
- 3,
- 100,
- 50000,
- promptTemplateFile,
- null,
- null,
- "INFO",
- "test-api-key",
- false
- );
-
- assertDoesNotThrow(() -> validator.validate(config),
- "HTTP scheme should be valid");
- }
-
- @Test
- void validate_apiBaseUrlNullScheme() throws Exception {
- Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
- Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
- Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
- Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
-
- StartConfiguration config = new StartConfiguration(
- sourceFolder,
- targetFolder,
- sqliteFile,
- URI.create("//api.example.com"), // Kein Schema
- "gpt-4",
- 30,
- 3,
- 100,
- 50000,
- promptTemplateFile,
- null,
- null,
- "INFO",
- "test-api-key",
- false
- );
-
- InvalidStartConfigurationException exception = assertThrows(
- InvalidStartConfigurationException.class,
- () -> validator.validate(config)
- );
- // Bei einer URI ohne Schema ist sie nicht absolut, daher kommt zuerst diese Fehlermeldung
- assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI"));
- }
-}
\ No newline at end of file
+}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java
new file mode 100644
index 0000000..3bf216f
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java
@@ -0,0 +1,351 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
+
+import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for {@link LegacyConfigurationMigrator}.
+ *
+ * Covers all mandatory test cases for the legacy-to-multi-provider configuration migration.
+ * Temporary files are managed via {@link TempDir} so no test artifacts remain on the file system.
+ */
+class LegacyConfigurationMigratorTest {
+
+ @TempDir
+ Path tempDir;
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ /** Full legacy configuration containing all four api.* keys plus other required keys. */
+ private static String fullLegacyContent() {
+ return "source.folder=./source\n"
+ + "target.folder=./target\n"
+ + "sqlite.file=./db.sqlite\n"
+ + "api.baseUrl=https://api.openai.com/v1\n"
+ + "api.model=gpt-4o\n"
+ + "api.timeoutSeconds=30\n"
+ + "max.retries.transient=3\n"
+ + "max.pages=10\n"
+ + "max.text.characters=5000\n"
+ + "prompt.template.file=./prompt.txt\n"
+ + "api.key=sk-test-legacy-key\n"
+ + "log.level=INFO\n"
+ + "log.ai.sensitive=false\n";
+ }
+
+ private Path writeLegacyFile(String name, String content) throws IOException {
+ Path file = tempDir.resolve(name);
+ Files.writeString(file, content, StandardCharsets.UTF_8);
+ return file;
+ }
+
+ private Properties loadProperties(Path file) throws IOException {
+ Properties props = new Properties();
+ props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8));
+ return props;
+ }
+
+ private LegacyConfigurationMigrator defaultMigrator() {
+ return new LegacyConfigurationMigrator();
+ }
+
+ // =========================================================================
+ // Mandatory test case 1
+ // =========================================================================
+
+ /**
+ * Legacy file with all four {@code api.*} keys is correctly migrated.
+ * Values in the migrated file must be identical to the originals; all other keys survive.
+ */
+ @Test
+ void migratesLegacyFileWithAllFlatKeys() throws IOException {
+ Path file = writeLegacyFile("app.properties", fullLegacyContent());
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ Properties migrated = loadProperties(file);
+ assertEquals("https://api.openai.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"));
+ assertEquals("gpt-4o", migrated.getProperty("ai.provider.openai-compatible.model"));
+ assertEquals("30", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"));
+ assertEquals("sk-test-legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey"));
+ assertEquals("openai-compatible", migrated.getProperty("ai.provider.active"));
+
+ // Old flat keys must be gone
+ assertFalse(migrated.containsKey("api.baseUrl"), "api.baseUrl must be removed");
+ assertFalse(migrated.containsKey("api.model"), "api.model must be removed");
+ assertFalse(migrated.containsKey("api.timeoutSeconds"), "api.timeoutSeconds must be removed");
+ assertFalse(migrated.containsKey("api.key"), "api.key must be removed");
+ }
+
+ // =========================================================================
+ // Mandatory test case 2
+ // =========================================================================
+
+ /**
+ * A {@code .bak} backup is created with the exact original content before any changes.
+ */
+ @Test
+ void createsBakBeforeOverwriting() throws IOException {
+ String original = fullLegacyContent();
+ Path file = writeLegacyFile("app.properties", original);
+ Path bakFile = tempDir.resolve("app.properties.bak");
+
+ assertFalse(Files.exists(bakFile), "No .bak should exist before migration");
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ assertTrue(Files.exists(bakFile), ".bak must be created during migration");
+ assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8),
+ ".bak must contain the exact original content");
+ }
+
+ // =========================================================================
+ // Mandatory test case 3
+ // =========================================================================
+
+ /**
+ * When {@code .bak} already exists, the new backup is written as {@code .bak.1}.
+ * Neither the existing {@code .bak} nor the new {@code .bak.1} is overwritten.
+ */
+ @Test
+ void bakSuffixIsIncrementedIfBakExists() throws IOException {
+ String original = fullLegacyContent();
+ Path file = writeLegacyFile("app.properties", original);
+
+ // Pre-create .bak with different content
+ Path existingBak = tempDir.resolve("app.properties.bak");
+ Files.writeString(existingBak, "# existing bak", StandardCharsets.UTF_8);
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ // Existing .bak must be untouched
+ assertEquals("# existing bak", Files.readString(existingBak, StandardCharsets.UTF_8),
+ "Existing .bak must not be overwritten");
+
+ // New backup must be .bak.1 with original content
+ Path newBak = tempDir.resolve("app.properties.bak.1");
+ assertTrue(Files.exists(newBak), ".bak.1 must be created when .bak already exists");
+ assertEquals(original, Files.readString(newBak, StandardCharsets.UTF_8),
+ ".bak.1 must contain the original content");
+ }
+
+ // =========================================================================
+ // Mandatory test case 4
+ // =========================================================================
+
+ /**
+ * A file already in the new multi-provider schema triggers no write and no {@code .bak}.
+ */
+ @Test
+ void noOpForAlreadyMigratedFile() throws IOException {
+ String newSchema = "ai.provider.active=openai-compatible\n"
+ + "ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1\n"
+ + "ai.provider.openai-compatible.model=gpt-4o\n"
+ + "ai.provider.openai-compatible.timeoutSeconds=30\n"
+ + "ai.provider.openai-compatible.apiKey=sk-key\n";
+ Path file = writeLegacyFile("app.properties", newSchema);
+ long modifiedBefore = Files.getLastModifiedTime(file).toMillis();
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ // File must not have been rewritten
+ assertEquals(modifiedBefore, Files.getLastModifiedTime(file).toMillis(),
+ "File modification time must not change for already-migrated files");
+
+ // No .bak should exist
+ Path bakFile = tempDir.resolve("app.properties.bak");
+ assertFalse(Files.exists(bakFile), "No .bak must be created for already-migrated files");
+ }
+
+ // =========================================================================
+ // Mandatory test case 5
+ // =========================================================================
+
+ /**
+ * After migration, the new parser and validator load the file without error.
+ */
+ @Test
+ void reloadAfterMigrationSucceeds() throws IOException {
+ Path file = writeLegacyFile("app.properties", fullLegacyContent());
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ // Reload and parse with the new parser+validator — must not throw
+ Properties props = loadProperties(file);
+ MultiProviderConfiguration config = assertDoesNotThrow(
+ () -> new MultiProviderConfigurationParser().parse(props),
+ "Migrated file must be parseable by MultiProviderConfigurationParser");
+ assertDoesNotThrow(
+ () -> new MultiProviderConfigurationValidator().validate(config),
+ "Migrated file must pass MultiProviderConfigurationValidator");
+ }
+
+ // =========================================================================
+ // Mandatory test case 6
+ // =========================================================================
+
+ /**
+ * When post-migration validation fails, a {@link ConfigurationLoadingException} is thrown
+ * and the {@code .bak} backup is preserved with the original content.
+ */
+ @Test
+ void migrationFailureKeepsBak() throws IOException {
+ String original = fullLegacyContent();
+ Path file = writeLegacyFile("app.properties", original);
+
+ // Validator that always rejects
+ MultiProviderConfigurationValidator failingValidator = new MultiProviderConfigurationValidator() {
+ @Override
+ public void validate(MultiProviderConfiguration config) {
+ throw new InvalidStartConfigurationException("Simulated validation failure");
+ }
+ };
+
+ LegacyConfigurationMigrator migrator = new LegacyConfigurationMigrator(
+ new MultiProviderConfigurationParser(), failingValidator);
+
+ assertThrows(ConfigurationLoadingException.class,
+ () -> migrator.migrateIfLegacy(file),
+ "Migration must throw ConfigurationLoadingException when post-migration validation fails");
+
+ // .bak must be preserved with original content
+ Path bakFile = tempDir.resolve("app.properties.bak");
+ assertTrue(Files.exists(bakFile), ".bak must be preserved after migration failure");
+ assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8),
+ ".bak content must match the original file content");
+ }
+
+ // =========================================================================
+ // Mandatory test case 7
+ // =========================================================================
+
+ /**
+ * A file that contains {@code ai.provider.active} but no legacy {@code api.*} keys
+ * is not considered legacy and triggers no migration.
+ */
+ @Test
+ void legacyDetectionRequiresAtLeastOneFlatKey() throws IOException {
+ String notLegacy = "ai.provider.active=openai-compatible\n"
+ + "source.folder=./source\n"
+ + "max.pages=10\n";
+ Path file = writeLegacyFile("app.properties", notLegacy);
+
+ Properties props = new Properties();
+ props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8));
+
+ boolean detected = defaultMigrator().isLegacyForm(props);
+
+ assertFalse(detected, "File with ai.provider.active and no api.* keys must not be detected as legacy");
+ }
+
+ // =========================================================================
+ // Mandatory test case 8
+ // =========================================================================
+
+ /**
+ * The four legacy values land in exactly the target keys in the openai-compatible namespace,
+ * and {@code ai.provider.active} is set to {@code openai-compatible}.
+ */
+ @Test
+ void legacyValuesEndUpInOpenAiCompatibleNamespace() throws IOException {
+ String content = "api.baseUrl=https://legacy.example.com/v1\n"
+ + "api.model=legacy-model\n"
+ + "api.timeoutSeconds=42\n"
+ + "api.key=legacy-key\n"
+ + "source.folder=./src\n";
+ Path file = writeLegacyFile("app.properties", content);
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ Properties migrated = loadProperties(file);
+ assertEquals("https://legacy.example.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"),
+ "api.baseUrl must map to ai.provider.openai-compatible.baseUrl");
+ assertEquals("legacy-model", migrated.getProperty("ai.provider.openai-compatible.model"),
+ "api.model must map to ai.provider.openai-compatible.model");
+ assertEquals("42", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"),
+ "api.timeoutSeconds must map to ai.provider.openai-compatible.timeoutSeconds");
+ assertEquals("legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey"),
+ "api.key must map to ai.provider.openai-compatible.apiKey");
+ assertEquals("openai-compatible", migrated.getProperty("ai.provider.active"),
+ "ai.provider.active must be set to openai-compatible");
+ }
+
+ // =========================================================================
+ // Mandatory test case 9
+ // =========================================================================
+
+ /**
+ * Keys unrelated to the legacy api.* set survive the migration with identical values.
+ */
+ @Test
+ void unrelatedKeysSurviveUnchanged() throws IOException {
+ String content = "source.folder=./my/source\n"
+ + "target.folder=./my/target\n"
+ + "sqlite.file=./my/db.sqlite\n"
+ + "max.pages=15\n"
+ + "max.text.characters=3000\n"
+ + "log.level=DEBUG\n"
+ + "log.ai.sensitive=false\n"
+ + "api.baseUrl=https://api.openai.com/v1\n"
+ + "api.model=gpt-4o\n"
+ + "api.timeoutSeconds=30\n"
+ + "api.key=sk-unrelated-test\n";
+ Path file = writeLegacyFile("app.properties", content);
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ Properties migrated = loadProperties(file);
+ assertEquals("./my/source", migrated.getProperty("source.folder"), "source.folder must be unchanged");
+ assertEquals("./my/target", migrated.getProperty("target.folder"), "target.folder must be unchanged");
+ assertEquals("./my/db.sqlite", migrated.getProperty("sqlite.file"), "sqlite.file must be unchanged");
+ assertEquals("15", migrated.getProperty("max.pages"), "max.pages must be unchanged");
+ assertEquals("3000", migrated.getProperty("max.text.characters"), "max.text.characters must be unchanged");
+ assertEquals("DEBUG", migrated.getProperty("log.level"), "log.level must be unchanged");
+ assertEquals("false", migrated.getProperty("log.ai.sensitive"), "log.ai.sensitive must be unchanged");
+ }
+
+ // =========================================================================
+ // Mandatory test case 10
+ // =========================================================================
+
+ /**
+ * Migration writes via a temporary {@code .tmp} file followed by a move/rename.
+ * After successful migration, no {@code .tmp} file remains, and the original path
+ * holds the fully migrated content (never partially overwritten).
+ */
+ @Test
+ void inPlaceWriteIsAtomic() throws IOException {
+ Path file = writeLegacyFile("app.properties", fullLegacyContent());
+ Path tmpFile = tempDir.resolve("app.properties.tmp");
+
+ defaultMigrator().migrateIfLegacy(file);
+
+ // .tmp must have been cleaned up (moved to target, not left behind)
+ assertFalse(Files.exists(tmpFile),
+ ".tmp file must not exist after migration (must have been moved to target)");
+
+ // Target must contain migrated content
+ Properties migrated = loadProperties(file);
+ assertTrue(migrated.containsKey("ai.provider.active"),
+ "Migrated file must contain ai.provider.active (complete write confirmed)");
+ assertTrue(migrated.containsKey("ai.provider.openai-compatible.model"),
+ "Migrated file must contain the new namespaced model key (complete write confirmed)");
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
new file mode 100644
index 0000000..4d7a5e3
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
@@ -0,0 +1,281 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
+
+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.MultiProviderConfiguration;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the multi-provider configuration parsing and validation pipeline.
+ *
+ * Covers all mandatory test cases for the new configuration schema as defined
+ * in the active work package specification.
+ */
+class MultiProviderConfigurationTest {
+
+ private static final Function
+ * Sub-case A: {@code OPENAI_COMPATIBLE_API_KEY} set, OpenAI active.
+ * Sub-case B: {@code ANTHROPIC_API_KEY} set, Claude active.
+ */
+ @Test
+ void envVarOverridesPropertiesApiKeyForActiveProvider() {
+ // Sub-case A: OpenAI active, OPENAI_COMPATIBLE_API_KEY set
+ Properties openAiProps = fullOpenAiProperties();
+ openAiProps.setProperty("ai.provider.openai-compatible.apiKey", "properties-key");
+
+ Function
* Tests cover valid configuration loading, missing mandatory properties,
- * invalid property values, and API-key environment variable precedence.
+ * invalid property values, and API-key environment variable precedence
+ * for the multi-provider schema.
*/
class PropertiesConfigurationPortAdapterTest {
@@ -42,13 +45,20 @@ class PropertiesConfigurationPortAdapterTest {
var config = adapter.loadConfiguration();
assertNotNull(config);
- // Use endsWith to handle platform-specific path separators
assertTrue(config.sourceFolder().toString().endsWith("source"));
assertTrue(config.targetFolder().toString().endsWith("target"));
assertTrue(config.sqliteFile().toString().endsWith("db.sqlite"));
- assertEquals("https://api.example.com", config.apiBaseUrl().toString());
- assertEquals("gpt-4", config.apiModel());
- assertEquals(30, config.apiTimeoutSeconds());
+ assertNotNull(config.multiProviderConfiguration());
+ assertEquals(AiProviderFamily.OPENAI_COMPATIBLE,
+ config.multiProviderConfiguration().activeProviderFamily());
+ assertEquals("https://api.example.com",
+ config.multiProviderConfiguration().activeProviderConfiguration().baseUrl());
+ assertEquals("gpt-4",
+ config.multiProviderConfiguration().activeProviderConfiguration().model());
+ assertEquals(30,
+ config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds());
+ assertEquals("test-api-key-from-properties",
+ config.multiProviderConfiguration().activeProviderConfiguration().apiKey());
assertEquals(3, config.maxRetriesTransient());
assertEquals(100, config.maxPages());
assertEquals(50000, config.maxTextCharacters());
@@ -56,57 +66,60 @@ class PropertiesConfigurationPortAdapterTest {
assertTrue(config.runtimeLockFile().toString().endsWith("lock.lock"));
assertTrue(config.logDirectory().toString().endsWith("logs"));
assertEquals("DEBUG", config.logLevel());
- assertEquals("test-api-key-from-properties", config.apiKey());
}
@Test
- void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsAbsent() throws Exception {
+ void loadConfiguration_rejectsBlankApiKeyWhenAbsentAndNoEnvVar() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
- var config = adapter.loadConfiguration();
-
- assertEquals("", config.apiKey(), "API key should be empty when not in properties and no env var");
+ assertThrows(
+ de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
+ adapter::loadConfiguration,
+ "Missing API key must be rejected as invalid configuration");
}
@Test
- void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsNull() throws Exception {
+ void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsNull() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function
+ * Covers schema migration (idempotency, nullable default for existing rows),
+ * write/read round-trips for both supported provider identifiers, and
+ * backward compatibility with databases created before provider tracking was introduced.
+ */
+class SqliteAttemptProviderPersistenceTest {
+
+ private String jdbcUrl;
+ private SqliteSchemaInitializationAdapter schemaAdapter;
+ private SqliteProcessingAttemptRepositoryAdapter repository;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ Path dbFile = tempDir.resolve("provider-test.db");
+ jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
+ schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
+ repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
+ }
+
+ // -------------------------------------------------------------------------
+ // Schema migration tests
+ // -------------------------------------------------------------------------
+
+ /**
+ * A fresh database must contain the {@code ai_provider} column after schema initialisation.
+ */
+ @Test
+ void addsProviderColumnOnFreshDb() throws SQLException {
+ schemaAdapter.initializeSchema();
+
+ assertThat(columnExists("processing_attempt", "ai_provider"))
+ .as("ai_provider column must exist in processing_attempt after fresh schema init")
+ .isTrue();
+ }
+
+ /**
+ * A database that already has the {@code processing_attempt} table without
+ * {@code ai_provider} (simulating an existing installation before this column was added)
+ * must receive the column via the idempotent schema evolution.
+ */
+ @Test
+ void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException {
+ // Bootstrap schema without the ai_provider column (simulate legacy DB)
+ createLegacySchema();
+
+ assertThat(columnExists("processing_attempt", "ai_provider"))
+ .as("ai_provider must not be present before evolution")
+ .isFalse();
+
+ // Running initializeSchema must add the column
+ schemaAdapter.initializeSchema();
+
+ assertThat(columnExists("processing_attempt", "ai_provider"))
+ .as("ai_provider column must be added by schema evolution")
+ .isTrue();
+ }
+
+ /**
+ * Running schema initialisation multiple times must not fail and must not change the schema.
+ */
+ @Test
+ void migrationIsIdempotent() throws SQLException {
+ schemaAdapter.initializeSchema();
+ // Second and third init must not throw or change the schema
+ schemaAdapter.initializeSchema();
+ schemaAdapter.initializeSchema();
+
+ assertThat(columnExists("processing_attempt", "ai_provider"))
+ .as("Column must still be present after repeated init calls")
+ .isTrue();
+ }
+
+ /**
+ * Rows that existed before the {@code ai_provider} column was added must have
+ * {@code NULL} as the column value, not a non-null default.
+ */
+ @Test
+ void existingRowsKeepNullProvider() throws SQLException {
+ // Create legacy schema and insert a row without ai_provider
+ createLegacySchema();
+ DocumentFingerprint fp = fingerprint("aa");
+ insertLegacyDocumentRecord(fp);
+ insertLegacyAttemptRow(fp, "READY_FOR_AI");
+
+ // Now evolve the schema
+ schemaAdapter.initializeSchema();
+
+ // Read the existing row — ai_provider must be NULL
+ List
+ * The provider selection is simulated at the data level here; the actual
+ * Claude adapter is wired in a later step.
+ */
+ @Test
+ void newAttemptsWriteClaudeProvider() {
+ schemaAdapter.initializeSchema();
+ DocumentFingerprint fp = fingerprint("cc");
+ insertDocumentRecord(fp);
+
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+ ProcessingAttempt attempt = new ProcessingAttempt(
+ fp, new RunId("run-claude"), 1, now, now.plusSeconds(1),
+ ProcessingStatus.READY_FOR_AI,
+ null, null, false,
+ "claude",
+ null, null, null, null, null, null,
+ null, null, null, null);
+
+ repository.save(attempt);
+
+ List
+ * Each constant represents a distinct API protocol family. Exactly one provider family
+ * is active per application run, selected via the {@code ai.provider.active} configuration property.
+ *
+ * The {@link #getIdentifier()} method returns the string that must appear as the value of
+ * {@code ai.provider.active} to activate the corresponding provider family.
+ * Use {@link #fromIdentifier(String)} to resolve a configuration string to the enum constant.
+ */
+public enum AiProviderFamily {
+
+ /** OpenAI-compatible Chat Completions API – usable with OpenAI itself and compatible third-party endpoints. */
+ OPENAI_COMPATIBLE("openai-compatible"),
+
+ /** Native Anthropic Messages API for Claude models. */
+ CLAUDE("claude");
+
+ private final String identifier;
+
+ AiProviderFamily(String identifier) {
+ this.identifier = identifier;
+ }
+
+ /**
+ * Returns the configuration identifier string for this provider family.
+ *
+ * This value corresponds to valid values of the {@code ai.provider.active} property.
+ *
+ * @return the configuration identifier, never {@code null}
+ */
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ /**
+ * Resolves a provider family from its configuration identifier string.
+ *
+ * The comparison is case-sensitive and matches the exact identifier strings
+ * defined by each constant (e.g., {@code "openai-compatible"}, {@code "claude"}).
+ *
+ * @param identifier the identifier as it appears in the {@code ai.provider.active} property;
+ * {@code null} returns an empty Optional
+ * @return the matching provider family, or {@link Optional#empty()} if not recognized
+ */
+ public static Optional
+ * Represents the resolved configuration for both supported AI provider families
+ * together with the selection of the one provider family that is active for this
+ * application run.
+ *
+ *
+ * Holds all parameters needed to connect to and authenticate with one AI provider endpoint.
+ * Instances are created by the configuration parser in the adapter layer; validation
+ * of required fields is performed by the corresponding validator.
+ *
+ *
* Contains all technical infrastructure and runtime configuration parameters
* loaded and validated at bootstrap time. This is a complete configuration model
- * for the entire application startup, including paths, API settings, persistence,
+ * for the entire application startup, including paths, AI provider selection, persistence,
* and operational parameters.
*
+ *
+ * The {@link MultiProviderConfiguration} encapsulates the active provider selection
+ * together with the per-provider connection parameters for all supported provider families.
+ * Exactly one provider family is active per run; the selection is driven by the
+ * {@code ai.provider.active} configuration property.
+ *
*
* The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated
@@ -25,9 +33,7 @@ public record StartConfiguration(
Path sourceFolder,
Path targetFolder,
Path sqliteFile,
- URI apiBaseUrl,
- String apiModel,
- int apiTimeoutSeconds,
+ MultiProviderConfiguration multiProviderConfiguration,
int maxRetriesTransient,
int maxPages,
int maxTextCharacters,
@@ -35,7 +41,6 @@ public record StartConfiguration(
Path runtimeLockFile,
Path logDirectory,
String logLevel,
- String apiKey,
/**
* Whether sensitive AI content (raw response, reasoning) may be written to log files.
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java
index 8b3628a..cc6d0bf 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java
@@ -42,6 +42,10 @@ import java.util.Objects;
* successful or skip attempts.
*
* Convenience factory for pre-check failures, skip events, and any attempt
- * that does not involve an AI call.
+ * that does not involve an AI call. The {@link #aiProvider()} field is set
+ * to {@code null}.
*
* @param fingerprint document identity; must not be null
* @param runId batch run identifier; must not be null
@@ -157,6 +164,6 @@ public record ProcessingAttempt(
return new ProcessingAttempt(
fingerprint, runId, attemptNumber, startedAt, endedAt,
status, failureClass, failureMessage, retryable,
- null, null, null, null, null, null, null, null, null, null);
+ null, null, null, null, null, null, null, null, null, null, null);
}
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
index 77a2894..ca4f5b6 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
@@ -154,15 +154,22 @@ public class DocumentProcessingCoordinator {
private final TargetFileCopyPort targetFileCopyPort;
private final ProcessingLogger logger;
private final int maxRetriesTransient;
+ private final String activeProviderIdentifier;
/**
- * Creates the document processing coordinator with all required ports, logger, and
- * the transient retry limit.
+ * Creates the document processing coordinator with all required ports, logger,
+ * the transient retry limit, and the active AI provider identifier.
*
* {@code maxRetriesTransient} is the maximum number of historised transient error attempts
* per fingerprint before the document is finalised to
* {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to
* reach this value finalises the document. Must be >= 1.
+ *
+ * {@code activeProviderIdentifier} is the opaque string identifier of the AI provider
+ * that is active for this run (e.g. {@code "openai-compatible"} or {@code "claude"}).
+ * It is written to the attempt history for every attempt that involves an AI call,
+ * enabling provider-level traceability per attempt without introducing
+ * provider-specific logic in the application layer.
*
* @param documentRecordRepository port for reading and writing the document master record;
* must not be null
@@ -176,8 +183,11 @@ public class DocumentProcessingCoordinator {
* @param logger for processing-related logging; must not be null
* @param maxRetriesTransient maximum number of historised transient error attempts
* before finalisation; must be >= 1
+ * @param activeProviderIdentifier opaque identifier of the active AI provider for this run;
+ * must not be null or blank
* @throws NullPointerException if any object parameter is null
- * @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1
+ * @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1, or
+ * if {@code activeProviderIdentifier} is blank
*/
public DocumentProcessingCoordinator(
DocumentRecordRepository documentRecordRepository,
@@ -186,11 +196,16 @@ public class DocumentProcessingCoordinator {
TargetFolderPort targetFolderPort,
TargetFileCopyPort targetFileCopyPort,
ProcessingLogger logger,
- int maxRetriesTransient) {
+ int maxRetriesTransient,
+ String activeProviderIdentifier) {
if (maxRetriesTransient < 1) {
throw new IllegalArgumentException(
"maxRetriesTransient must be >= 1, got: " + maxRetriesTransient);
}
+ Objects.requireNonNull(activeProviderIdentifier, "activeProviderIdentifier must not be null");
+ if (activeProviderIdentifier.isBlank()) {
+ throw new IllegalArgumentException("activeProviderIdentifier must not be blank");
+ }
this.documentRecordRepository =
Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null");
this.processingAttemptRepository =
@@ -203,6 +218,7 @@ public class DocumentProcessingCoordinator {
Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
this.logger = Objects.requireNonNull(logger, "logger must not be null");
this.maxRetriesTransient = maxRetriesTransient;
+ this.activeProviderIdentifier = activeProviderIdentifier;
}
/**
@@ -503,7 +519,7 @@ public class DocumentProcessingCoordinator {
ProcessingAttempt successAttempt = new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, attemptStart, now,
ProcessingStatus.SUCCESS, null, null, false,
- null, null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null, null, null, null, null,
resolvedFilename);
DocumentRecord successRecord = buildSuccessRecord(
@@ -951,6 +967,7 @@ public class DocumentProcessingCoordinator {
yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
+ activeProviderIdentifier,
ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(),
@@ -964,6 +981,7 @@ public class DocumentProcessingCoordinator {
yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
+ activeProviderIdentifier,
ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(),
@@ -976,6 +994,7 @@ public class DocumentProcessingCoordinator {
yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
+ activeProviderIdentifier,
ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(),
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java
index 789ccb2..55a8b3d 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java
@@ -90,7 +90,7 @@ class DocumentProcessingCoordinatorTest {
unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
@@ -250,7 +250,8 @@ class DocumentProcessingCoordinatorTest {
// With maxRetriesTransient=1, the very first transient error finalises the document
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1,
+ "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new TechnicalDocumentError(
@@ -668,7 +669,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null));
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -686,7 +687,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -705,7 +706,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -724,7 +725,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -742,7 +743,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown());
unitOfWorkPort.failOnExecute = true;
DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -761,7 +762,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -780,7 +781,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
unitOfWorkPort.failOnExecute = true;
@@ -848,6 +849,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
+ null,
"model", "prompt", 1, 100, "{}", "reason",
null, DateSource.AI_PROVIDED, "Rechnung", null);
attemptRepo.savedAttempts.add(badProposal);
@@ -871,7 +873,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithFailingFolder.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null);
@@ -893,7 +895,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithFailingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null);
@@ -915,6 +917,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
+ null,
"model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"A".repeat(21), null);
@@ -941,6 +944,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
+ null,
"model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"Rechnung-2026", null);
@@ -980,7 +984,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> {
@@ -1014,7 +1018,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null);
@@ -1044,7 +1048,8 @@ class DocumentProcessingCoordinatorTest {
CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
- new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1);
+ new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1,
+ "openai-compatible");
boolean result = coordinatorWith1Retry.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null);
@@ -1079,7 +1084,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1105,7 +1110,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
- 1 /* maxRetriesTransient=1 → immediately final */);
+ 1 /* maxRetriesTransient=1 → immediately final */, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1128,7 +1133,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCountingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart,
@@ -1197,7 +1202,8 @@ class DocumentProcessingCoordinatorTest {
// maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL
DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2,
+ "openai-compatible");
DocumentProcessingOutcome transientError = new TechnicalDocumentError(candidate, "Timeout", null);
// Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1
@@ -1233,6 +1239,7 @@ class DocumentProcessingCoordinatorTest {
return new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
+ "openai-compatible",
"gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null);
}
@@ -1495,7 +1502,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentTerminalSuccess(
buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero())));
@@ -1516,7 +1523,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(
buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0))));
@@ -1537,7 +1544,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown());
coordinatorWithCapturing.process(candidate, fingerprint,
@@ -1560,7 +1567,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
// Existing record already has one content error — second content error finalises
recordRepo.setLookupResult(new DocumentKnownProcessable(
buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0))));
@@ -1596,7 +1603,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1612,6 +1619,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
+ null,
"model", "prompt", 1, 100, "{}", "reason",
null, DateSource.AI_PROVIDED, "Rechnung", null);
attemptRepo.savedAttempts.add(badProposal);
@@ -1620,7 +1628,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1639,7 +1647,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1658,7 +1666,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart,
@@ -1680,7 +1688,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart,
@@ -1702,7 +1710,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), bothFail, capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null);
@@ -1723,7 +1731,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart,
@@ -1843,7 +1851,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart,
@@ -1873,7 +1881,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1897,7 +1905,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
- DEFAULT_MAX_RETRIES_TRANSIENT);
+ DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java
index e70ffca..040912a 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java
@@ -356,6 +356,7 @@ class TargetFilenameBuildingServiceTest {
Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
+ "openai-compatible",
"gpt-4", "prompt-v1.txt", 1, 100,
"{}", "reasoning text",
date, DateSource.AI_PROVIDED, title,
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
index 590bb0b..205668f 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
@@ -469,7 +469,7 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
- new NoOpProcessingLogger(), 3) {
+ new NoOpProcessingLogger(), 3, "openai-compatible") {
@Override
public boolean processDeferredOutcome(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
@@ -517,7 +517,7 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
- new NoOpProcessingLogger(), 3) {
+ new NoOpProcessingLogger(), 3, "openai-compatible") {
private int callCount = 0;
@Override
@@ -760,7 +760,8 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWork,
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
+ "openai-compatible");
// Fingerprint port returns the pre-defined fingerprint for this candidate
FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);
@@ -807,7 +808,8 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWork,
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
+ "openai-compatible");
FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);
@@ -860,7 +862,8 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWork,
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
+ "openai-compatible");
FingerprintPort perCandidateFingerprintPort = candidate -> {
if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp);
@@ -1152,7 +1155,8 @@ class BatchRunProcessingUseCaseTest {
private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
NoOpDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
+ "openai-compatible");
}
}
@@ -1164,7 +1168,8 @@ class BatchRunProcessingUseCaseTest {
TrackingDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
- new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
+ new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
+ "openai-compatible");
}
@Override
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java
new file mode 100644
index 0000000..1704a7a
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java
@@ -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.
+ *
+ * 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}.
+ *
+ *
+ * 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");
+ }
+}
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 431b161..449e040 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
@@ -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.
*
+ *
+ * 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.
+ *
*
* The production constructor wires the following key adapters:
*
+ * 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:
*
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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).
+ * 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;
}
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java
new file mode 100644
index 0000000..73e88fe
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java
@@ -0,0 +1,134 @@
+package de.gecheckt.pdf.umbenenner.bootstrap;
+
+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;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link AiProviderSelector}.
+ *
+ * Covers selection of the OpenAI-compatible adapter, selection of the Claude adapter,
+ * hard failure for unregistered provider families, and null-safety.
+ */
+@ExtendWith(MockitoExtension.class)
+class AiProviderSelectorTest {
+
+ private final AiProviderSelector selector = new AiProviderSelector();
+
+ private static ProviderConfiguration validOpenAiConfig() {
+ return new ProviderConfiguration("gpt-4", 30, "https://api.example.com", "test-key");
+ }
+
+ private static ProviderConfiguration validClaudeConfig() {
+ return new ProviderConfiguration(
+ "claude-3-5-sonnet-20241022", 60, "https://api.anthropic.com", "sk-ant-key");
+ }
+
+ // =========================================================================
+ // Mandatory test case: bootstrapWiresOpenAiCompatibleAdapterWhenActive
+ // =========================================================================
+
+ /**
+ * When the active provider family is OPENAI_COMPATIBLE, the selector must return
+ * an {@link OpenAiHttpAdapter} instance.
+ */
+ @Test
+ void bootstrapWiresOpenAiCompatibleAdapterWhenActive() {
+ AiInvocationPort port = selector.select(AiProviderFamily.OPENAI_COMPATIBLE, validOpenAiConfig());
+
+ assertNotNull(port, "Selector must return a non-null AiInvocationPort");
+ assertInstanceOf(OpenAiHttpAdapter.class, port,
+ "OPENAI_COMPATIBLE must be wired to OpenAiHttpAdapter");
+ }
+
+ // =========================================================================
+ // Mandatory test case: bootstrapSelectsClaudeWhenActive (AP-005)
+ // =========================================================================
+
+ /**
+ * When the active provider family is CLAUDE, the selector must return an
+ * {@link AnthropicClaudeHttpAdapter} instance.
+ */
+ @Test
+ void bootstrapSelectsClaudeWhenActive() {
+ AiInvocationPort port = selector.select(AiProviderFamily.CLAUDE, validClaudeConfig());
+
+ assertNotNull(port, "Selector must return a non-null AiInvocationPort for Claude");
+ assertInstanceOf(AnthropicClaudeHttpAdapter.class, port,
+ "CLAUDE must be wired to AnthropicClaudeHttpAdapter");
+ }
+
+ // =========================================================================
+ // Mandatory test case: bootstrapFailsHardWhenActiveProviderUnknown
+ // =========================================================================
+
+ /**
+ * A null provider family must result in a NullPointerException.
+ * This guards against uninitialised / null active-provider state that
+ * should be caught by the validator before reaching the selector.
+ */
+ @Test
+ void bootstrapFailsHardWhenActiveProviderUnknown() {
+ assertThrows(NullPointerException.class,
+ () -> selector.select(null, validOpenAiConfig()),
+ "Null provider family must throw NullPointerException");
+ }
+
+ // =========================================================================
+ // Mandatory test case: bootstrapFailsHardWhenSelectedProviderHasNoImplementation
+ // =========================================================================
+
+ /**
+ * A provider family with no registered adapter implementation must throw
+ * {@link InvalidStartConfigurationException} immediately, preventing the
+ * application from starting.
+ *
+ * Both known families (OPENAI_COMPATIBLE and CLAUDE) are now registered.
+ * This test uses a Mockito mock of {@link AiProviderFamily} to represent a
+ * hypothetical future or unknown provider, confirming that the selector's
+ * fallback guard remains in place for any unregistered family.
+ */
+ @Test
+ void bootstrapFailsHardWhenSelectedProviderHasNoImplementation() {
+ // Create a mock AiProviderFamily that does not equal any registered constant
+ AiProviderFamily unknownFamily = mock(AiProviderFamily.class);
+ when(unknownFamily.getIdentifier()).thenReturn("unknown-future-provider");
+
+ ProviderConfiguration anyConfig = new ProviderConfiguration(
+ "some-model", 30, "https://unknown.example.com", "some-key");
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> selector.select(unknownFamily, anyConfig),
+ "A provider family with no registered adapter must throw InvalidStartConfigurationException");
+
+ assertTrue(ex.getMessage().contains("unknown-future-provider")
+ || ex.getMessage().toLowerCase().contains("no ai adapter"),
+ "Error message must reference the unregistered provider or indicate missing registration");
+ }
+
+ // =========================================================================
+ // Additional safety: null ProviderConfiguration
+ // =========================================================================
+
+ @Test
+ void selectThrowsWhenProviderConfigurationIsNull() {
+ assertThrows(NullPointerException.class,
+ () -> selector.select(AiProviderFamily.OPENAI_COMPATIBLE, null),
+ "Null ProviderConfiguration must throw NullPointerException");
+ }
+}
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java
index 567a29a..b3055cb 100644
--- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java
@@ -4,6 +4,9 @@ import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
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.configuration.ConfigurationLoadingException;
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+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;
@@ -17,10 +20,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
-import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
@@ -51,9 +52,7 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")),
Files.createFile(tempDir.resolve("db.sqlite")),
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -61,7 +60,6 @@ class BootstrapRunnerEdgeCasesTest {
null, // null runtimeLockFile
tempDir.resolve("logs"),
"INFO",
- "test-key",
false
);
@@ -101,14 +99,12 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")),
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30, 3, 100, 50000,
+ validMultiProviderConfig(),
+ 3, 100, 50000,
Files.createFile(tempDir.resolve("prompt.txt")),
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
- "test-key",
false
);
@@ -128,14 +124,12 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")),
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30, 3, 100, 50000,
+ validMultiProviderConfig(),
+ 3, 100, 50000,
Files.createFile(tempDir.resolve("prompt.txt")),
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
- "test-key",
false
);
@@ -157,13 +151,12 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")),
Files.createFile(tempDir.resolve("db.sqlite")),
- URI.create("https://api.example.com"),
- "gpt-4", 30, 3, 100, 50000,
+ validMultiProviderConfig(),
+ 3, 100, 50000,
Files.createFile(tempDir.resolve("prompt.txt")),
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
- "test-key",
false
);
@@ -226,9 +219,9 @@ class BootstrapRunnerEdgeCasesTest {
Path dbFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptFile = Files.createFile(tempDir.resolve("prompt.txt"));
return new StartConfiguration(sourceDir, targetDir, dbFile,
- URI.create("https://api.example.com"), "gpt-4", 30, 3, 100, 50000,
+ validMultiProviderConfig(), 3, 100, 50000,
promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"),
- "INFO", "key", false);
+ "INFO", false);
} catch (Exception e) {
throw new RuntimeException(e);
}
@@ -342,9 +335,19 @@ class BootstrapRunnerEdgeCasesTest {
"logAiSensitive=true must resolve to LOG_SENSITIVE_CONTENT");
}
- // =========================================================================
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static MultiProviderConfiguration validMultiProviderConfig() {
+ ProviderConfiguration openAiConfig = new ProviderConfiguration(
+ "gpt-4", 30, "https://api.example.com", "test-api-key");
+ return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
+ }
+
+ // -------------------------------------------------------------------------
// Mocks
- // =========================================================================
+ // -------------------------------------------------------------------------
private static class MockConfigurationPort implements ConfigurationPort {
private final Path tempDir;
@@ -373,13 +376,16 @@ class BootstrapRunnerEdgeCasesTest {
Files.createFile(promptTemplateFile);
}
+ ProviderConfiguration openAiConfig = new ProviderConfiguration(
+ "gpt-4", 30, "https://api.example.com", "test-api-key");
+ MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
+ AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
+
return new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ multiConfig,
3,
100,
50000,
@@ -387,7 +393,6 @@ class BootstrapRunnerEdgeCasesTest {
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
- "test-api-key",
false
);
} catch (Exception e) {
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java
index 790f339..969e015 100644
--- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java
@@ -4,6 +4,11 @@ import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
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.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.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+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;
@@ -13,13 +18,21 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
-import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
@@ -176,9 +189,7 @@ class BootstrapRunnerTest {
sourceDir,
targetDir,
dbFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
3,
100,
50000,
@@ -186,7 +197,6 @@ class BootstrapRunnerTest {
Paths.get(""), // empty – simulates unconfigured runtime.lock.file
tempDir.resolve("logs"),
"INFO",
- "test-key",
false
);
@@ -262,9 +272,7 @@ class BootstrapRunnerTest {
sourceDir,
targetDir,
dbFile,
- java.net.URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ validMultiProviderConfig(),
0, // max.retries.transient = 0 is invalid (must be >= 1)
100,
50000,
@@ -272,7 +280,6 @@ class BootstrapRunnerTest {
tempDir.resolve("lock-mrt.lock"),
null,
"INFO",
- "test-key",
false
);
@@ -346,6 +353,121 @@ class BootstrapRunnerTest {
assertEquals(1, exitCode, "Schema initialization failure should return exit code 1");
}
+ // =========================================================================
+ // Mandatory test case: activeProviderIsLoggedAtRunStart
+ // =========================================================================
+
+ /**
+ * The active AI provider identifier must be logged at INFO level during the bootstrap phase,
+ * after configuration is loaded and validated but before batch processing begins.
+ */
+ @Test
+ void activeProviderIsLoggedAtRunStart() throws Exception {
+ ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
+ BootstrapRunner runner = new BootstrapRunner(
+ () -> mockConfigPort,
+ lockFile -> new MockRunLockPort(),
+ StartConfigurationValidator::new,
+ jdbcUrl -> new MockSchemaInitializationPort(),
+ (config, lock) -> new MockRunBatchProcessingUseCase(true),
+ SchedulerBatchCommand::new
+ );
+
+ List
+ * Covers the full migration path: detection, backup creation, in-place rewrite,
+ * re-validation, and subsequent successful configuration load.
+ */
+ @Test
+ void legacyFileEndToEndStillRuns(@TempDir Path workDir) throws Exception {
+ Path sourceDir = Files.createDirectories(workDir.resolve("source"));
+ Path targetDir = Files.createDirectories(workDir.resolve("target"));
+ Path dbParentDir = Files.createDirectories(workDir.resolve("data"));
+ Path promptDir = Files.createDirectories(workDir.resolve("config/prompts"));
+ Path promptFile = Files.createFile(promptDir.resolve("template.txt"));
+ Files.writeString(promptFile, "Test prompt template.");
+
+ Path configFile = workDir.resolve("application.properties");
+ String legacyConfig = String.format(
+ "source.folder=%s%n"
+ + "target.folder=%s%n"
+ + "sqlite.file=%s%n"
+ + "api.baseUrl=https://api.example.com%n"
+ + "api.model=gpt-4%n"
+ + "api.timeoutSeconds=30%n"
+ + "api.key=test-legacy-key%n"
+ + "max.retries.transient=3%n"
+ + "max.pages=10%n"
+ + "max.text.characters=5000%n"
+ + "prompt.template.file=%s%n",
+ sourceDir.toAbsolutePath(),
+ targetDir.toAbsolutePath(),
+ dbParentDir.resolve("db.sqlite").toAbsolutePath(),
+ promptFile.toAbsolutePath()
+ );
+ Files.writeString(configFile, legacyConfig);
+
+ BootstrapRunner runner = new BootstrapRunner(
+ () -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile),
+ () -> new PropertiesConfigurationPortAdapter(configFile),
+ lockFile -> new MockRunLockPort(),
+ StartConfigurationValidator::new,
+ jdbcUrl -> new MockSchemaInitializationPort(),
+ (config, lock) -> new MockRunBatchProcessingUseCase(true),
+ SchedulerBatchCommand::new
+ );
+
+ int exitCode = runner.run();
+
+ assertEquals(0, exitCode,
+ "Legacy configuration must be migrated and the run must complete successfully");
+ assertTrue(Files.exists(workDir.resolve("application.properties.bak")),
+ "Backup file must exist after migration");
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static MultiProviderConfiguration validMultiProviderConfig() {
+ ProviderConfiguration openAiConfig = new ProviderConfiguration(
+ "gpt-4", 30, "https://api.example.com", "test-api-key");
+ return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
+ }
+
// -------------------------------------------------------------------------
// Mocks
// -------------------------------------------------------------------------
@@ -377,13 +499,16 @@ class BootstrapRunnerTest {
Files.createFile(promptTemplateFile);
}
+ ProviderConfiguration openAiConfig = new ProviderConfiguration(
+ "gpt-4", 30, "https://api.example.com", "test-api-key");
+ MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
+ AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
+
return new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
- URI.create("https://api.example.com"),
- "gpt-4",
- 30,
+ multiConfig,
3,
100,
50000,
@@ -391,7 +516,6 @@ class BootstrapRunnerTest {
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
- "test-api-key",
false
);
} catch (Exception e) {
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java
new file mode 100644
index 0000000..c139d31
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java
@@ -0,0 +1,198 @@
+package de.gecheckt.pdf.umbenenner.bootstrap;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
+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.StartConfigurationValidator;
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+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.out.AiInvocationPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Smoke tests for the complete bootstrap wiring of the active AI provider.
+ *
+ * Each test drives the full {@link BootstrapRunner} startup sequence — configuration loading,
+ * validation, schema initialisation, and use-case factory — but replaces the real AI HTTP client
+ * with a wiring probe inside the use-case factory. No real HTTP calls are made.
+ *
+ *
+ * These are regression smoke tests for the provider selection path. They do not exercise
+ * real document processing; the use-case factory captures the selected port and immediately
+ * returns a no-op use case.
+ */
+class BootstrapSmokeTest {
+
+ // =========================================================================
+ // Pflicht-Testfall: smokeBootstrapWithOpenAiCompatibleActive
+ // =========================================================================
+
+ /**
+ * Verifies that the bootstrap path correctly wires {@link OpenAiHttpAdapter} when
+ * {@code ai.provider.active=openai-compatible} is configured.
+ *
+ * The {@link AiProviderSelector} is called inside the use-case factory with the
+ * active provider configuration; the resulting {@link AiInvocationPort} instance
+ * is captured and asserted to be an {@link OpenAiHttpAdapter}.
+ */
+ @Test
+ void smokeBootstrapWithOpenAiCompatibleActive(@TempDir Path tempDir) throws Exception {
+ AtomicReference
+ * The {@link AiProviderSelector} is called inside the use-case factory with the
+ * active provider configuration; the resulting {@link AiInvocationPort} instance
+ * is captured and asserted to be an {@link AnthropicClaudeHttpAdapter}.
+ */
+ @Test
+ void smokeBootstrapWithClaudeActive(@TempDir Path tempDir) throws Exception {
+ AtomicReference
- * Creates the {@code source/}, {@code target/} subdirectories and a minimal prompt
- * file, initializes the SQLite schema, and wires all adapters.
+ * Initializes a fully wired end-to-end test context rooted in {@code tempDir},
+ * using the default provider identifier {@code "openai-compatible"}.
*
* @param tempDir the JUnit {@code @TempDir} or any writable temporary directory
* @return a ready-to-use context; caller is responsible for closing it
* @throws Exception if schema initialization or directory/file creation fails
*/
public static E2ETestContext initialize(Path tempDir) throws Exception {
+ return initializeWithProvider(tempDir, "openai-compatible");
+ }
+
+ /**
+ * Initializes a fully wired end-to-end test context rooted in {@code tempDir} with
+ * a configurable provider identifier written into each attempt's history record.
+ *
+ * Creates the {@code source/}, {@code target/} subdirectories and a minimal prompt
+ * file, initializes the SQLite schema, and wires all adapters.
+ *
+ * @param tempDir the JUnit {@code @TempDir} or any writable temporary directory
+ * @param providerIdentifier the provider identifier stored in {@code ai_provider} for each
+ * attempt (e.g. {@code "openai-compatible"} or {@code "claude"})
+ * @return a ready-to-use context; caller is responsible for closing it
+ * @throws Exception if schema initialization or directory/file creation fails
+ */
+ public static E2ETestContext initializeWithProvider(Path tempDir, String providerIdentifier)
+ throws Exception {
Path sourceFolder = Files.createDirectories(tempDir.resolve("source"));
Path targetFolder = Files.createDirectories(tempDir.resolve("target"));
Path lockFile = tempDir.resolve("run.lock");
@@ -189,7 +210,8 @@ public final class E2ETestContext implements AutoCloseable {
return new E2ETestContext(
sourceFolder, targetFolder, lockFile, promptFile,
- jdbcUrl, documentRepo, attemptRepo, new StubAiInvocationPort());
+ jdbcUrl, documentRepo, attemptRepo, new StubAiInvocationPort(),
+ providerIdentifier);
}
// =========================================================================
@@ -377,7 +399,8 @@ public final class E2ETestContext implements AutoCloseable {
targetFolderPort,
targetFileCopyPort,
coordinatorLogger,
- MAX_RETRIES_TRANSIENT);
+ MAX_RETRIES_TRANSIENT,
+ providerIdentifier);
PromptPort promptPort = new FilesystemPromptPortAdapter(promptFile);
ClockPort clockPort = new SystemClockAdapter();
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java
new file mode 100644
index 0000000..5eafe4c
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java
@@ -0,0 +1,397 @@
+package de.gecheckt.pdf.umbenenner.bootstrap.e2e;
+
+import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end regression and provider-identifier tests verifying the complete integration
+ * of the multi-provider extension with the existing batch processing pipeline.
+ *
+ *
+ * Runs the two-phase happy path (AI call → {@code PROPOSAL_READY} in run 1,
+ * file copy → {@code SUCCESS} in run 2) with the {@code openai-compatible} provider
+ * identifier and verifies the final state matches the expected success outcome.
+ * This is the canonical regression check for the existing OpenAI flow.
+ */
+ @Test
+ void regressionExistingOpenAiSuiteGreen(@TempDir Path tempDir) throws Exception {
+ try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
+ ctx.createSearchablePdf("regression.pdf", SAMPLE_PDF_TEXT);
+ Path pdfPath = ctx.sourceFolder().resolve("regression.pdf");
+ DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
+
+ // Run 1: AI produces naming proposal
+ BatchRunOutcome run1 = ctx.runBatch();
+ assertThat(run1).isEqualTo(BatchRunOutcome.SUCCESS);
+ assertThat(resolveRecord(ctx, fp).overallStatus())
+ .isEqualTo(ProcessingStatus.PROPOSAL_READY);
+ assertThat(ctx.listTargetFiles()).isEmpty();
+
+ // Run 2: Finalization without AI call
+ ctx.aiStub.resetInvocationCount();
+ BatchRunOutcome run2 = ctx.runBatch();
+ assertThat(run2).isEqualTo(BatchRunOutcome.SUCCESS);
+ assertThat(ctx.aiStub.invocationCount())
+ .as("Existing OpenAI path must not re-invoke AI when PROPOSAL_READY exists")
+ .isEqualTo(0);
+ assertThat(resolveRecord(ctx, fp).overallStatus())
+ .isEqualTo(ProcessingStatus.SUCCESS);
+ assertThat(ctx.listTargetFiles()).hasSize(1);
+ }
+ }
+
+ // =========================================================================
+ // Pflicht-Testfall: e2eOpenAiRunWritesProviderIdentifierToHistory
+ // =========================================================================
+
+ /**
+ * Verifies that a batch run using the {@code openai-compatible} provider identifier
+ * persists {@code "openai-compatible"} in the {@code ai_provider} field of the
+ * attempt history record.
+ */
+ @Test
+ void e2eOpenAiRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
+ try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
+ ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
+ DocumentFingerprint fp = ctx.computeFingerprint(ctx.sourceFolder().resolve("doc.pdf"));
+
+ ctx.runBatch();
+
+ List
+ * The AI invocation itself is still handled by the configurable {@link StubAiInvocationPort};
+ * only the provider identifier string (written by the coordinator) is the subject of this test.
+ */
+ @Test
+ void e2eClaudeRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
+ try (E2ETestContext ctx = E2ETestContext.initializeWithProvider(tempDir, "claude")) {
+ ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
+ DocumentFingerprint fp = ctx.computeFingerprint(ctx.sourceFolder().resolve("doc.pdf"));
+
+ ctx.runBatch();
+
+ List
+ *
+ *
+ * @param request the request representation with prompt and document text
+ * @return an {@link HttpRequest} ready to send
+ */
+ private HttpRequest buildRequest(AiRequestRepresentation request) {
+ URI endpoint = buildEndpointUri();
+ String requestBody = buildJsonRequestBody(request);
+ // Capture for test inspection (test-only field)
+ this.lastBuiltJsonBody = requestBody;
+
+ return HttpRequest.newBuilder(endpoint)
+ .header("content-type", CONTENT_TYPE)
+ .header(API_KEY_HEADER, apiKey)
+ .header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+ .timeout(Duration.ofSeconds(apiTimeoutSeconds))
+ .build();
+ }
+
+ /**
+ * Composes the endpoint URI from the configured base URL.
+ *
+ *
+ *
*
*
*
- *
*
*
*
* @param request the request representation with prompt and document text
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidator.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidator.java
index 93143c8..858538a 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidator.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidator.java
@@ -10,6 +10,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+
/**
* Validates {@link StartConfiguration} before processing can begin.
* Legacy form
+ * A configuration file is considered legacy if it contains at least one of the flat property
+ * keys ({@code api.baseUrl}, {@code api.model}, {@code api.timeoutSeconds}, {@code api.key})
+ * and does not already contain {@code ai.provider.active}.
+ *
+ * Migration procedure
+ *
+ *
+ */
+public class LegacyConfigurationMigrator {
+
+ private static final Logger LOG = LogManager.getLogger(LegacyConfigurationMigrator.class);
+
+ /** Legacy flat key for base URL, replaced during migration. */
+ static final String LEGACY_BASE_URL = "api.baseUrl";
+
+ /** Legacy flat key for model name, replaced during migration. */
+ static final String LEGACY_MODEL = "api.model";
+
+ /** Legacy flat key for timeout, replaced during migration. */
+ static final String LEGACY_TIMEOUT = "api.timeoutSeconds";
+
+ /** Legacy flat key for API key, replaced during migration. */
+ static final String LEGACY_API_KEY = "api.key";
+
+ private static final String[][] LEGACY_KEY_MAPPINGS = {
+ {LEGACY_BASE_URL, "ai.provider.openai-compatible.baseUrl"},
+ {LEGACY_MODEL, "ai.provider.openai-compatible.model"},
+ {LEGACY_TIMEOUT, "ai.provider.openai-compatible.timeoutSeconds"},
+ {LEGACY_API_KEY, "ai.provider.openai-compatible.apiKey"},
+ };
+
+ private final MultiProviderConfigurationParser parser;
+ private final MultiProviderConfigurationValidator validator;
+
+ /**
+ * Creates a migrator backed by default parser and validator instances.
+ */
+ public LegacyConfigurationMigrator() {
+ this(new MultiProviderConfigurationParser(), new MultiProviderConfigurationValidator());
+ }
+
+ /**
+ * Creates a migrator with injected parser and validator.
+ *
+ *
+ *
+ * ai.provider.active – required; must be "openai-compatible" or "claude"
+ * ai.provider.openai-compatible.baseUrl – required for active OpenAI-compatible provider
+ * ai.provider.openai-compatible.model – required for active OpenAI-compatible provider
+ * ai.provider.openai-compatible.timeoutSeconds
+ * ai.provider.openai-compatible.apiKey
+ * ai.provider.claude.baseUrl – optional; defaults to https://api.anthropic.com
+ * ai.provider.claude.model – required for active Claude provider
+ * ai.provider.claude.timeoutSeconds
+ * ai.provider.claude.apiKey
+ *
+ *
+ * Environment-variable precedence for API keys
+ *
+ *
+ * Each environment variable is applied only to its own provider family; the variables
+ * of different families are never mixed.
+ *
+ * Error handling
+ *
+ *
+ *
+ *
+ *
+ * Required fields of the inactive provider are intentionally not enforced.
+ * Legacy-state migration
@@ -150,6 +153,9 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
/**
* Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}.
+ *
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("AnthropicClaudeHttpAdapter")
+class AnthropicClaudeHttpAdapterTest {
+
+ private static final String API_BASE_URL = "https://api.anthropic.com";
+ private static final String API_MODEL = "claude-3-5-sonnet-20241022";
+ private static final String API_KEY = "sk-ant-test-key-12345";
+ private static final int TIMEOUT_SECONDS = 60;
+
+ @Mock
+ private HttpClient httpClient;
+
+ private ProviderConfiguration testConfiguration;
+ private AnthropicClaudeHttpAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
+ adapter = new AnthropicClaudeHttpAdapter(testConfiguration, httpClient);
+ }
+
+ // =========================================================================
+ // Pflicht-Testfall 1: claudeAdapterBuildsCorrectRequest
+ // =========================================================================
+
+ /**
+ * Verifies that the adapter constructs the correct HTTP request:
+ * URL with {@code /v1/messages} path, method POST, all three required headers
+ * ({@code x-api-key}, {@code anthropic-version}, {@code content-type}), and
+ * a body with {@code model}, {@code max_tokens > 0}, and {@code messages} containing
+ * exactly one user message with the document text.
+ */
+ @Test
+ @DisplayName("claudeAdapterBuildsCorrectRequest: correct URL, method, headers, and body")
+ void claudeAdapterBuildsCorrectRequest() throws Exception {
+ HttpResponse
@@ -56,6 +54,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
*
*/
@ExtendWith(MockitoExtension.class)
@@ -70,28 +70,12 @@ class OpenAiHttpAdapterTest {
@Mock
private HttpClient httpClient;
- private StartConfiguration testConfiguration;
+ private ProviderConfiguration testConfiguration;
private OpenAiHttpAdapter adapter;
@BeforeEach
void setUp() {
- testConfiguration = new StartConfiguration(
- Paths.get("/source"),
- Paths.get("/target"),
- Paths.get("/db.sqlite"),
- URI.create(API_BASE_URL),
- API_MODEL,
- TIMEOUT_SECONDS,
- 5,
- 100,
- 5000,
- Paths.get("/prompt.txt"),
- Paths.get("/lock"),
- Paths.get("/logs"),
- "INFO",
- API_KEY,
- false
- );
+ testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
// Use the package-private constructor with injected mock HttpClient
adapter = new OpenAiHttpAdapter(testConfiguration, httpClient);
}
@@ -242,7 +226,6 @@ class OpenAiHttpAdapterTest {
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
- // Verify the timeout was actually configured on the request
assertThat(capturedRequest.timeout())
.as("HttpRequest timeout should be present")
.isPresent()
@@ -437,23 +420,8 @@ class OpenAiHttpAdapterTest {
@Test
@DisplayName("should throw IllegalArgumentException when API base URL is null")
void testNullApiBaseUrlThrowsException() {
- StartConfiguration invalidConfig = new StartConfiguration(
- Paths.get("/source"),
- Paths.get("/target"),
- Paths.get("/db.sqlite"),
- null, // Invalid: null base URL
- API_MODEL,
- TIMEOUT_SECONDS,
- 5,
- 100,
- 5000,
- Paths.get("/prompt.txt"),
- Paths.get("/lock"),
- Paths.get("/logs"),
- "INFO",
- API_KEY,
- false
- );
+ ProviderConfiguration invalidConfig = new ProviderConfiguration(
+ API_MODEL, TIMEOUT_SECONDS, null, API_KEY);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
@@ -463,23 +431,8 @@ class OpenAiHttpAdapterTest {
@Test
@DisplayName("should throw IllegalArgumentException when API model is null")
void testNullApiModelThrowsException() {
- StartConfiguration invalidConfig = new StartConfiguration(
- Paths.get("/source"),
- Paths.get("/target"),
- Paths.get("/db.sqlite"),
- URI.create(API_BASE_URL),
- null, // Invalid: null model
- TIMEOUT_SECONDS,
- 5,
- 100,
- 5000,
- Paths.get("/prompt.txt"),
- Paths.get("/lock"),
- Paths.get("/logs"),
- "INFO",
- API_KEY,
- false
- );
+ ProviderConfiguration invalidConfig = new ProviderConfiguration(
+ null, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
@@ -489,23 +442,8 @@ class OpenAiHttpAdapterTest {
@Test
@DisplayName("should throw IllegalArgumentException when API model is blank")
void testBlankApiModelThrowsException() {
- StartConfiguration invalidConfig = new StartConfiguration(
- Paths.get("/source"),
- Paths.get("/target"),
- Paths.get("/db.sqlite"),
- URI.create(API_BASE_URL),
- " ", // Invalid: blank model
- TIMEOUT_SECONDS,
- 5,
- 100,
- 5000,
- Paths.get("/prompt.txt"),
- Paths.get("/lock"),
- Paths.get("/logs"),
- "INFO",
- API_KEY,
- false
- );
+ ProviderConfiguration invalidConfig = new ProviderConfiguration(
+ " ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
@@ -516,25 +454,9 @@ class OpenAiHttpAdapterTest {
@DisplayName("should handle empty API key gracefully")
void testEmptyApiKeyHandled() throws Exception {
// Arrange
- StartConfiguration configWithEmptyKey = new StartConfiguration(
- Paths.get("/source"),
- Paths.get("/target"),
- Paths.get("/db.sqlite"),
- URI.create(API_BASE_URL),
- API_MODEL,
- TIMEOUT_SECONDS,
- 5,
- 100,
- 5000,
- Paths.get("/prompt.txt"),
- Paths.get("/lock"),
- Paths.get("/logs"),
- "INFO",
- "", // Empty key
- false
- );
-
- OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(configWithEmptyKey, httpClient);
+ OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(
+ new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""),
+ httpClient);
HttpResponseInvariants
+ *
+ *
+ *
+ * @param activeProviderFamily the selected provider family for this run; {@code null}
+ * indicates that {@code ai.provider.active} was absent or
+ * held an unrecognised value – the validator will reject this
+ * @param openAiCompatibleConfig configuration for the OpenAI-compatible provider family
+ * @param claudeConfig configuration for the Anthropic Claude provider family
+ */
+public record MultiProviderConfiguration(
+ AiProviderFamily activeProviderFamily,
+ ProviderConfiguration openAiCompatibleConfig,
+ ProviderConfiguration claudeConfig) {
+
+ /**
+ * Returns the {@link ProviderConfiguration} for the currently active provider family.
+ *
+ * @return the active provider's configuration, never {@code null} when
+ * {@link #activeProviderFamily()} is not {@code null}
+ * @throws NullPointerException if {@code activeProviderFamily} is {@code null}
+ */
+ public ProviderConfiguration activeProviderConfiguration() {
+ return switch (activeProviderFamily) {
+ case OPENAI_COMPATIBLE -> openAiCompatibleConfig;
+ case CLAUDE -> claudeConfig;
+ };
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java
new file mode 100644
index 0000000..885d350
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java
@@ -0,0 +1,34 @@
+package de.gecheckt.pdf.umbenenner.application.config.provider;
+
+/**
+ * Immutable configuration for a single AI provider family.
+ * Field semantics
+ *
+ *
+ *
+ * @param model the AI model name; {@code null} when not configured
+ * @param timeoutSeconds HTTP timeout in seconds; {@code 0} when not configured
+ * @param baseUrl the base URL of the API endpoint; {@code null} when not configured
+ * (only applicable to providers without a built-in default)
+ * @param apiKey the resolved API key; blank when not configured
+ */
+public record ProviderConfiguration(
+ String model,
+ int timeoutSeconds,
+ String baseUrl,
+ String apiKey) {
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java
index dac24f2..8ed46fb 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java
@@ -1,16 +1,24 @@
package de.gecheckt.pdf.umbenenner.application.config.startup;
-import java.net.URI;
import java.nio.file.Path;
+import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
+
/**
* Typed immutable configuration model for PDF Umbenenner startup parameters.
* AI provider configuration
+ * AI content sensitivity ({@code log.ai.sensitive})
* Registered providers
+ *
+ *
+ *
+ * Hard start failure
+ * Active AI provider
+ * Exit code semantics
*
*
+ *
- *
*
What is verified
+ *
+ *
+ *
+ * Scope
+ * Test cases covered
+ *
+ *
+ */
+class ProviderIdentifierE2ETest {
+
+ private static final String SAMPLE_PDF_TEXT =
+ "Testrechnung Musterstadt Datum 20.03.2024 Betrag 89,00 EUR";
+
+ // =========================================================================
+ // Pflicht-Testfall: regressionExistingOpenAiSuiteGreen
+ // =========================================================================
+
+ /**
+ * Regression proof: the OpenAI-compatible provider path still produces the correct
+ * end-to-end outcome after the multi-provider extension.
+ * What is verified
+ *
+ *
+ */
+ @Test
+ void e2eMigrationFromLegacyDemoConfig(@TempDir Path tempDir) throws Exception {
+ // --- Arrange: write a legacy config file ---
+ Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
+ Path targetDir = Files.createDirectories(tempDir.resolve("target"));
+ Path configFile = tempDir.resolve("application.properties");
+
+ String legacyContent =
+ "source.folder=" + sourceDir.toAbsolutePath().toString().replace('\\', '/') + "\n"
+ + "target.folder=" + targetDir.toAbsolutePath().toString().replace('\\', '/') + "\n"
+ + "sqlite.file=" + tempDir.resolve("db.sqlite").toAbsolutePath().toString().replace('\\', '/') + "\n"
+ + "api.baseUrl=https://api.openai.com/v1\n"
+ + "api.model=gpt-4o-mini\n"
+ + "api.timeoutSeconds=30\n"
+ + "api.key=test-legacy-key-demo\n"
+ + "max.retries.transient=3\n"
+ + "max.pages=10\n"
+ + "max.text.characters=5000\n";
+ Files.writeString(configFile, legacyContent);
+
+ // --- Act: run migration ---
+ new LegacyConfigurationMigrator().migrateIfLegacy(configFile);
+
+ // --- Assert: backup exists with original content ---
+ Path bakFile = tempDir.resolve("application.properties.bak");
+ assertThat(Files.exists(bakFile))
+ .as(".bak file must be created before migration overwrites the original")
+ .isTrue();
+ assertThat(Files.readString(bakFile))
+ .as(".bak content must equal the original file content verbatim")
+ .isEqualTo(legacyContent);
+
+ // --- Assert: migrated file has new schema ---
+ Properties migrated = new Properties();
+ try (var reader = Files.newBufferedReader(configFile)) {
+ migrated.load(reader);
+ }
+ assertThat(migrated.getProperty("ai.provider.active"))
+ .as("Migrated file must contain ai.provider.active=openai-compatible")
+ .isEqualTo("openai-compatible");
+ assertThat(migrated.getProperty("ai.provider.openai-compatible.baseUrl"))
+ .as("Legacy api.baseUrl must be migrated to openai-compatible namespace")
+ .isEqualTo("https://api.openai.com/v1");
+ assertThat(migrated.getProperty("ai.provider.openai-compatible.model"))
+ .isEqualTo("gpt-4o-mini");
+ assertThat(migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"))
+ .isEqualTo("30");
+ assertThat(migrated.getProperty("ai.provider.openai-compatible.apiKey"))
+ .isEqualTo("test-legacy-key-demo");
+ assertThat(migrated.getProperty("max.retries.transient"))
+ .as("Non-AI keys must survive migration unchanged")
+ .isEqualTo("3");
+ assertThat(migrated.getProperty("max.pages")).isEqualTo("10");
+ // Legacy flat keys must no longer be present
+ assertThat(migrated.getProperty("api.baseUrl"))
+ .as("Legacy api.baseUrl must not remain in migrated file")
+ .isNull();
+
+ // --- Assert: batch run after migration completes successfully ---
+ // The E2ETestContext is independent of the properties file; it wires directly.
+ // This proves that the application pipeline works correctly for an openai-compatible run,
+ // which is the provider selected by migration.
+ try (E2ETestContext ctx = E2ETestContext.initialize(tempDir.resolve("e2e"))) {
+ ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
+ BatchRunOutcome outcome = ctx.runBatch();
+ assertThat(outcome)
+ .as("Batch run after migration must succeed (provider: openai-compatible)")
+ .isEqualTo(BatchRunOutcome.SUCCESS);
+ }
+ }
+
+ // =========================================================================
+ // Pflicht-Testfall: legacyDataFromBeforeV11RemainsReadable
+ // =========================================================================
+
+ /**
+ * Proves backward compatibility with databases created before the {@code ai_provider}
+ * column was introduced.
+ *
+ * What is verified
+ *
+ *
+ */
+ @Test
+ void legacyDataFromBeforeV11RemainsReadable(@TempDir Path tempDir) throws Exception {
+ // Build a database without the ai_provider column (simulates pre-extension installation)
+ String jdbcUrl = "jdbc:sqlite:"
+ + tempDir.resolve("legacy.db").toAbsolutePath().toString().replace('\\', '/');
+ createPreExtensionSchema(jdbcUrl);
+
+ // Insert a legacy attempt row (no ai_provider column present in schema at this point)
+ DocumentFingerprint legacyFp = fingerprint("aabbcc");
+ insertLegacyData(jdbcUrl, legacyFp);
+
+ // Initialize the full schema — this must add ai_provider idempotently
+ de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter schema =
+ new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter(jdbcUrl);
+ schema.initializeSchema();
+
+ // Read back the legacy attempt — must not throw, aiProvider must be null
+ de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter repo =
+ new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
+ List