From 8286d0f0e59496934f3ebd7fed918ac8843025c1 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Wed, 22 Apr 2026 09:53:03 +0200 Subject: [PATCH] =?UTF-8?q?Titell=C3=A4nge=20nun=20parametrisierbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 3 +- README.md | 5 +- config/application-local.example.properties | 3 + config/application-test.example.properties | 2 + config/prompts/template.txt | 4 +- docs/betrieb.md | 3 +- docs/examples/application.properties | 6 + docs/gui-bedienanleitung.md | 14 ++ docs/specs/V2_6_-_Spezifikation.md | 120 +++++++++++++ docs/specs/fachliche-anforderungen.md | 6 +- docs/specs/technik-und-architektur.md | 7 +- .../gui/GuiConfigurationEditorWorkspace.java | 8 +- .../in/gui/editor/GuiApiKeyMerger.java | 1 + .../GuiConfigurationEditorStateFactory.java | 2 + .../GuiConfigurationTemplateFactory.java | 3 + .../in/gui/editor/GuiConfigurationValues.java | 42 +++-- ...iConfigurationEditorWorkspaceSaveTest.java | 2 +- .../adapter/in/gui/GuiDirtyStateTest.java | 1 + .../in/gui/GuiEditorFieldBindingTest.java | 4 + .../in/gui/GuiEditorValidationSmokeTest.java | 157 ++++++++++++++++++ .../GuiTechnicalTestCoordinatorSmokeTest.java | 6 +- .../gui/GuiUnsavedChangesGuardSmokeTest.java | 1 + .../in/gui/GuiWindowTitleFormatterTest.java | 1 + .../editor/ConfirmationDialogContentTest.java | 2 +- ...uiConfigurationEditorStateFactoryTest.java | 78 +++++++++ .../GuiConfigurationEditorStateTest.java | 2 + .../GuiConfigurationTemplateFactoryTest.java | 4 + .../editor/GuiConfigurationValuesTest.java | 57 +++++++ .../StartConfigurationValidator.java | 40 +++++ .../PropertiesConfigurationPortAdapter.java | 38 +++++ .../FilesystemResourceCreationAdapter.java | 9 +- ...AnthropicClaudeAdapterIntegrationTest.java | 6 +- .../StartConfigurationValidatorTest.java | 100 +++++++++++ ...ropertiesConfigurationPortAdapterTest.java | 84 ++++++++++ ...FilesystemResourceCreationAdapterTest.java | 10 +- .../config/startup/StartConfiguration.java | 11 ++ .../application/service/AiNamingService.java | 28 +++- .../service/AiRequestComposer.java | 25 ++- .../service/AiResponseValidator.java | 23 ++- .../DocumentProcessingCoordinator.java | 23 ++- .../TargetFilenameBuildingService.java | 25 ++- .../editor/EditorConfigurationValidator.java | 69 ++++++++ .../editor/EditorValidationInput.java | 4 + .../technicaltest/CorrectionSuggestion.java | 19 ++- .../technicaltest/DefaultPromptTemplate.java | 28 +++- .../TechnicalTestOrchestrator.java | 50 +++++- .../service/AiNamingServiceTest.java | 28 +++- .../service/AiRequestComposerTest.java | 43 ++++- .../service/AiResponseValidatorTest.java | 27 ++- .../DocumentProcessingCoordinatorTest.java | 68 ++++---- .../TargetFilenameBuildingServiceTest.java | 68 +++++--- .../BatchRunProcessingUseCaseTest.java | 21 ++- .../EditorConfigurationValidatorTest.java | 146 +++++++++++++--- .../CorrectionExecutionReportTest.java | 2 +- .../CorrectionExecutionServiceTest.java | 12 +- .../technicaltest/CorrectionPlanTest.java | 2 +- .../CorrectionSuggestionTest.java | 13 +- .../DefaultPromptTemplateTest.java | 37 +++-- .../ProviderTechnicalTestServiceTest.java | 6 +- .../TechnicalTestOrchestratorTest.java | 8 +- .../TechnicalTestReportTest.java | 2 +- .../TechnicalTestRequestTest.java | 2 +- .../umbenenner/bootstrap/BootstrapRunner.java | 6 +- .../GuiConfigurationPropertiesWriter.java | 2 + ...ootstrapRunnerConfigPathSemanticsTest.java | 2 +- .../BootstrapRunnerEdgeCasesTest.java | 10 +- .../BootstrapRunnerStartupDispatchTest.java | 2 +- .../bootstrap/BootstrapRunnerTest.java | 3 + .../bootstrap/BootstrapSmokeTest.java | 1 + .../GuiConfigurationPropertiesWriterTest.java | 16 ++ .../bootstrap/e2e/E2ETestContext.java | 8 +- .../domain/model/AiFunctionalFailure.java | 2 +- .../domain/model/NamingProposal.java | 7 +- .../packaging/application.example.properties | 6 + 74 files changed, 1450 insertions(+), 236 deletions(-) create mode 100644 docs/specs/V2_6_-_Spezifikation.md create mode 100644 pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactoryTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 1f4ccdb..8ac787b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und ## Globale fachliche Leitplanken - Zielformat: `YYYY-MM-DD - Titel.pdf` - Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ... -- Die **20 Zeichen** gelten nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit +- Die **konfigurierte maximale Titellänge** gilt nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit - Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt - Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen - Eigennamen bleiben unverändert @@ -276,6 +276,7 @@ Verbindlich zweckmäßige Parameter: - `max.retries.transient` – max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig) - `max.pages` – Seitenlimit - `max.text.characters` – maximale Zeichenzahl für KI-Eingabe +- `max.title.length` – maximale Länge des Basistitels in Zeichen (gültiger Bereich 10..120, Default 60) - `prompt.template.file` – externe Prompt-Datei - `log.ai.sensitive` – sensible KI-Logausgabe freischalten (Boolean, Default: `false`) - `runtime.lock.file` – Lock-Datei (optional) diff --git a/README.md b/README.md index b8e7777..422651e 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ YYYY-MM-DD - Titel(2).pdf Wichtige Regeln: -- die **20 Zeichen** beziehen sich nur auf den **Basistitel** -- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen +- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel** +- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge - Titel werden auf **Deutsch** erzeugt - Eigennamen bleiben unverändert - Quelldateien werden **nie** überschrieben, verschoben oder verändert @@ -118,6 +118,7 @@ Typische Bereiche sind: - Timeout - Seitenlimit - Textlimit für KI-Aufrufe +- maximale Titellänge (`max.title.length`, Default 60, Bereich 10..120) - Prompt-Datei - Logging diff --git a/config/application-local.example.properties b/config/application-local.example.properties index 3389d12..736a882 100644 --- a/config/application-local.example.properties +++ b/config/application-local.example.properties @@ -28,6 +28,9 @@ max.pages=10 # Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird. max.text.characters=5000 +# Maximale Länge des Basistitels in Zeichen (10..120). Default 60. +max.title.length=60 + # Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator # in der Versuchshistorie. prompt.template.file=./config/prompts/template.txt diff --git a/config/application-test.example.properties b/config/application-test.example.properties index 4862e18..123e3d5 100644 --- a/config/application-test.example.properties +++ b/config/application-test.example.properties @@ -13,6 +13,8 @@ sqlite.file=./work/test/pdf-umbenenner-test.db max.retries.transient=1 max.pages=5 max.text.characters=2000 +# Maximale Länge des Basistitels in Zeichen (10..120). Default 60. +max.title.length=60 prompt.template.file=./config/prompts/template.txt # --------------------------------------------------------------------------- diff --git a/config/prompts/template.txt b/config/prompts/template.txt index 097d751..bd09024 100644 --- a/config/prompts/template.txt +++ b/config/prompts/template.txt @@ -2,7 +2,7 @@ Du bist ein Assistent zur automatischen Benennung gescannter PDF-Dokumente. Analysiere den folgenden Dokumenttext und ermittle: -1. Einen inhaltlich passenden deutschen Titel (maximal 20 Zeichen, nur Buchstaben und Leerzeichen, keine Abkürzungen, keine generischen Bezeichnungen wie "Dokument", "Datei", "Scan" oder "PDF") +1. Einen inhaltlich passenden deutschen Titel (maximal {MAX_TITLE_LENGTH} Zeichen, nur Buchstaben und Leerzeichen, keine Abkürzungen, keine generischen Bezeichnungen wie "Dokument", "Datei", "Scan" oder "PDF") 2. Das relevanteste Datum des Dokuments Datumsermittlung nach Priorität: @@ -15,7 +15,7 @@ Datumsermittlung nach Priorität: Titelregeln: - Titel auf Deutsch formulieren - Eigennamen (Personen, Firmen, Orte) unverändert übernehmen -- Maximal 20 Zeichen (nur der Basistitel, ohne Datumspräfix) +- Maximal {MAX_TITLE_LENGTH} Zeichen (nur der Basistitel, ohne Datumspräfix) - Keine Sonderzeichen außer Leerzeichen - Eindeutig und verständlich, nicht generisch diff --git a/docs/betrieb.md b/docs/betrieb.md index 34c4bb1..1277bfc 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -152,6 +152,7 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in: | `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) | +| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 10–19 und 100–120 erzeugen eine Startwarnung. | | `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) | ### Provider-Parameter @@ -271,7 +272,7 @@ YYYY-MM-DD - Titel(1).pdf YYYY-MM-DD - Titel(2).pdf ``` -Das Suffix zählt nicht zu den 20 Zeichen des Basistitels. +Das Suffix zählt nicht zur konfigurierten maximalen Titellänge des Basistitels. --- diff --git a/docs/examples/application.properties b/docs/examples/application.properties index 3ce5f36..5808f78 100644 --- a/docs/examples/application.properties +++ b/docs/examples/application.properties @@ -102,6 +102,12 @@ max.pages=10 # Standardvorlage der GUI: 5000. max.text.characters=5000 +# Maximale Länge des Basistitels in Zeichen (10..120). Default 60. +# Werte unter 10 oder ueber 120 verhindern den Start. +# Werte 10-19: Warnung (fuer die meisten Dokumente nicht empfohlen). +# Werte 100-120: Warnung (Dateiname wird sehr lang, Kompatibilitaet mit verschluesselten Volumes pruefen). +max.title.length=60 + # --------------------------------------------------------------------------- # Optionale Parameter # --------------------------------------------------------------------------- diff --git a/docs/gui-bedienanleitung.md b/docs/gui-bedienanleitung.md index e45d630..c7cc0b5 100644 --- a/docs/gui-bedienanleitung.md +++ b/docs/gui-bedienanleitung.md @@ -246,6 +246,20 @@ Wirtschaftliche Warnschwellen für `max.text.characters`: `max.pages` wird als Plausibilitäts- und Performance-Hinweis behandelt. +Validierungsregeln für `max.title.length` (Feld „Max. Titellänge (Zeichen)" im Bereich „Verarbeitungslimits"): + +| Wertebereich | Bewertung | +|---|---| +| Kein Wert / leer | Fehler – Pflichtfeld, Konfiguration nicht lauffähig | +| Keine Ganzzahl (z. B. „abc") | Fehler – ungültiger Typ | +| Kleiner als 10 | Fehler – Minimum ist 10 Zeichen | +| 10 – 19 | Warnung – Titellänge unter 20 Zeichen ist für die meisten Dokumente nicht empfohlen | +| 20 – 99 | Normaler Betrieb, keine Meldung | +| 100 – 120 | Warnung – Dateiname wird sehr lang, Kompatibilität mit verschlüsselten Volumes prüfen | +| Größer als 120 | Fehler – überschreitet sicheres Limit für verschlüsselte Volumes | + +Warnungen verhindern das Speichern nicht. Fehler markieren den Stand als nicht lauffähig; Speichern ist dennoch erlaubt, jedoch erscheint ein deutlicher Hinweis im Meldungsbereich. + --- ## 7. Provider-Bedienung und Modellabruf diff --git a/docs/specs/V2_6_-_Spezifikation.md b/docs/specs/V2_6_-_Spezifikation.md new file mode 100644 index 0000000..e22756d --- /dev/null +++ b/docs/specs/V2_6_-_Spezifikation.md @@ -0,0 +1,120 @@ +# V2.6 – Titellänge parametrisierbar machen + +**Status:** Entwurf +**Erstellt:** 2026-04-22 +**Autor:** Marcus (mit Claude als Mentor) + +--- + +## Ziel + +Der maximale Basistitel für KI-generierte PDF-Namen wird nicht mehr hardcodiert, +sondern ist über die Konfigurationsdatei steuerbar. Alle bisherigen Magic Numbers +(20 und 60 Zeichen) werden durch den konfigurierten Wert ersetzt. + +--- + +## Hintergrund + +### Bisheriger Zustand +- Titellänge war mit 20 Zeichen im Prompt und 60 Zeichen in der Validierung hardcodiert +- Kein zentraler Konfigurationsparameter, Werte über ~20 Dateien verstreut +- 60-Zeichen-Limit wurde im Rahmen des Produkttests als pragmatischer Zwischenwert eingeführt + +### Motivation +- Verschiedene Einsatzszenarien erfordern unterschiedliche Titellängen +- Dateinamenlimits je nach Zielsystem unterschiedlich (siehe Recherche unten) + +### Recherchierte Dateinamenlimits (nur Dateiname, ohne Pfad) + +| System | Limit | +|---|---| +| Windows 10 / Windows Server 2022 (NTFS) | 255 Zeichen | +| Synology NAS – Btrfs (unverschlüsselt) | 255 Zeichen | +| Synology NAS – Btrfs (verschlüsselt) | ~143 Zeichen | + +**Hinweis:** Der generierte Dateiname hat das Format `YYYY-MM-DD - .pdf`, +was bereits 18 Zeichen Overhead bedeutet (Datum + Trennzeichen + Dateiendung). +Das sicherste Maximum für verschlüsselte Synology-Volumes ist daher **120 Zeichen** +für den Basistitel (143 − 18 = 125, mit Puffer auf 120 gerundet). + +--- + +## Fachliche Anforderungen + +### Neuer Konfigurationsparameter + +- **Name:** `ai.title.max.length` (finale Benennung obliegt der Implementierung) +- **Typ:** positive Ganzzahl +- **Defaultwert:** `60` (bisheriger Wert bleibt erhalten, kein Breaking Change) +- **Speicherort:** `.properties`-Konfigurationsdatei + +--- + +### Validierungsregeln + +| Wert | Typ | Verhalten | +|---|---|---| +| Kein Wert / leer | Fehler | Pflichtfeld, Start wird abgebrochen | +| Keine Ganzzahl (z. B. „abc", „1.5") | Fehler | Ungültiger Typ, Start wird abgebrochen | +| < 1 | Fehler | Wert muss positiv sein, Start wird abgebrochen | +| 1–9 | Fehler | Minimum ist 10 Zeichen, Start wird abgebrochen | +| 10–19 | Warnung | „Titellänge unter 20 Zeichen ist für die meisten Dokumente nicht empfohlen" | +| 20–99 | OK | Normaler Betrieb, keine Meldung | +| 100–120 | Warnung | „Hohe Titellänge – Dateiname wird sehr lang, Kompatibilität mit verschlüsselten Volumes prüfen" | +| > 120 | Fehler | Überschreitet sicheres Limit für verschlüsselte Synology-Volumes, Start wird abgebrochen | + +--- + +### GUI – Konfigurationseditor + +- Neues Texteingabefeld im Bereich **„Verarbeitungslimits"** +- Beschriftung: **„Max. Titellänge (Zeichen)"** +- Validierung erfolgt beim Speichern – ungültige Werte werden **nicht** gespeichert +- Warnungen und Fehlermeldungen erscheinen im **Meldungsbereich** (unten in der GUI) +- Warnungen blockieren das Speichern **nicht**, Fehler hingegen schon + +--- + +### Verarbeitung / Backend + +- Alle hardcodierten `20`- und `60`-Zeichen-Limits werden durch den konfigurierten Wert ersetzt +- **Keine Magic Numbers** mehr im Produktionscode +- Der Wert wird beim Start geladen, validiert und an alle betroffenen Komponenten weitergereicht +- Betroffen sind mindestens: + - `AiResponseValidator` + - `TargetFilenameBuildingService` + - Prompt-Template (Hinweistext an die KI) + - JavaDoc aller betroffenen Klassen + +--- + +### Prompt-Template + +- Der Hinweis auf die Zeichenbegrenzung im Prompt-Template (`config/prompts/template.txt`) + wird ebenfalls dynamisch mit dem konfigurierten Wert befüllt +- **Hinweis:** Das Prompt-Template liegt außerhalb des JARs und wird zur Laufzeit gelesen. + Die Implementierung muss sicherstellen, dass der konfigurierte Wert zur Laufzeit + in den Prompt eingesetzt wird (z. B. per Platzhalter-Ersetzung). + +--- + +## Nicht in V2.6 enthalten + +- Automatisches Kürzen von zu langen KI-Titeln +- Pfadlängen-Validierung (Gesamtpfad inkl. Ordner) +- Unterschiedliche Limits je nach Zielsystem (nur ein globaler Wert) + +--- + +## Abnahmekriterien + +- [ ] Neuer Parameter ist in der `.properties`-Datei konfigurierbar +- [ ] Defaultwert 60 ist abwärtskompatibel (bestehende Configs ohne den Parameter funktionieren) +- [ ] Alle Validierungsregeln greifen korrekt (Fehler blockieren Start/Speichern, Warnungen nicht) +- [ ] GUI zeigt das neue Feld im richtigen Bereich +- [ ] Meldungsbereich zeigt passende Warn- und Fehlertexte +- [ ] Keine hardcodierten 20- oder 60-Zeichen-Limits mehr im Produktionscode +- [ ] Prompt-Template enthält den konfigurierten Wert zur Laufzeit +- [ ] Alle bestehenden Tests werden angepasst +- [ ] `mvn clean verify` ist grün diff --git a/docs/specs/fachliche-anforderungen.md b/docs/specs/fachliche-anforderungen.md index 38ba30f..beba1d5 100644 --- a/docs/specs/fachliche-anforderungen.md +++ b/docs/specs/fachliche-anforderungen.md @@ -66,7 +66,7 @@ Fallback auf aktuelles Datum ist erlaubt, wenn kein belastbares Datum eindeutig ### 4.3 Titel -- maximal **20 Zeichen (Basistitel)** +- maximal **konfigurierbare Anzahl Zeichen (Basistitel, Default 60, gültiger Bereich 10..120)** - verständlich und eindeutig - keine Sonderzeichen außer Leerzeichen @@ -87,7 +87,7 @@ Bei Namenskonflikten: Regel: -- 20 Zeichen gelten nur für den Basistitel +- die konfigurierte maximale Titellänge gilt nur für den Basistitel - Suffix wird zusätzlich ergänzt --- @@ -192,7 +192,7 @@ Ein Ergebnis ist korrekt, wenn: - Format stimmt - Datum korrekt ist -- Titel max. 20 Zeichen hat +- Titel die konfigurierte maximale Länge einhält - Dubletten korrekt behandelt wurden - Begründung vorhanden ist - Ergebnis reproduzierbar ist diff --git a/docs/specs/technik-und-architektur.md b/docs/specs/technik-und-architektur.md index 9f915e4..2fb33d4 100644 --- a/docs/specs/technik-und-architektur.md +++ b/docs/specs/technik-und-architektur.md @@ -55,8 +55,8 @@ YYYY-MM-DD - Titel(2).pdf ``` Dabei gilt: -- die **20 Zeichen** beziehen sich nur auf den **Basistitel** -- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen +- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel** +- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge - die Quelldatei wird **nie** überschrieben oder verändert --- @@ -290,7 +290,7 @@ Der Titel muss technisch diese Regeln erfüllen: - Deutsch - verständlich - eindeutig genug für den Dokumentkontext -- maximal **20 Zeichen** als Basistitel +- maximal die **konfigurierte Titellänge** als Basistitel (Default 60, gültiger Bereich 10..120) - keine unzulässigen Windows-Dateinamenzeichen - keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF` - Eigennamen bleiben unverändert @@ -532,6 +532,7 @@ Verbindlich zweckmäßige Parameter: - `max.retries.transient` - `max.pages` - `max.text.characters` +- `max.title.length` - `prompt.template.file` Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens: diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 9e51f3b..16fea93 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -1370,7 +1370,7 @@ public final class GuiConfigurationEditorWorkspace { // ========================================================================= /** - * Builds the "Verarbeitungslimits" section with text fields for the three numeric limit + * Builds the "Verarbeitungslimits" section with text fields for the numeric limit * parameters and a checkbox for the sensitive-logging flag. * * @return the card node for the "Verarbeitungslimits" section @@ -1392,6 +1392,11 @@ public final class GuiConfigurationEditorWorkspace { val -> updateValues(editorState.values().withMaxTextCharacters(val))); addSimpleRow(grid, row++, "Maximale Zeichenzahl:", maxCharsField); + TextField maxTitleLengthField = boundTextField( + editorState.values().maxTitleLength(), + val -> updateValues(editorState.values().withMaxTitleLength(val))); + addSimpleRow(grid, row++, "Max. Titellänge (Zeichen):", maxTitleLengthField); + TextField maxRetriesField = boundTextField( editorState.values().maxRetriesTransient(), val -> updateValues(editorState.values().withMaxRetriesTransient(val))); @@ -1587,6 +1592,7 @@ public final class GuiConfigurationEditorWorkspace { values.maxRetriesTransient(), values.maxPages(), values.maxTextCharacters(), + values.maxTitleLength(), claudeState.baseUrl(), claudeState.model(), claudeState.timeoutSeconds(), diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java index ba95413..a035eba 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java @@ -92,6 +92,7 @@ public final class GuiApiKeyMerger { current.maxRetriesTransient(), current.maxPages(), current.maxTextCharacters(), + current.maxTitleLength(), current.logAiSensitive(), current.activeProviderFamily(), merged); diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java index b32f10b..a047000 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java @@ -25,6 +25,7 @@ public final class GuiConfigurationEditorStateFactory { private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient"; private static final String PROP_MAX_PAGES = "max.pages"; private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters"; + private static final String PROP_MAX_TITLE_LENGTH = "max.title.length"; private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive"; private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active"; private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl"; @@ -74,6 +75,7 @@ public final class GuiConfigurationEditorStateFactory { propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT), propertyOrBlank(properties, PROP_MAX_PAGES), propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS), + propertyOrBlank(properties, PROP_MAX_TITLE_LENGTH), propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE), propertyOrBlank(properties, PROP_ACTIVE_PROVIDER), providerConfigurations); diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java index dc0c518..781d45f 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java @@ -24,6 +24,7 @@ public final class GuiConfigurationTemplateFactory { private static final String MAX_RETRIES_TRANSIENT = "3"; private static final String MAX_PAGES = "10"; private static final String MAX_TEXT_CHARACTERS = "5000"; + private static final String DEFAULT_MAX_TITLE_LENGTH = "60"; private static final String OPENAI_BASE_URL = "https://api.openai.com/v1"; private static final String OPENAI_MODEL = "gpt-4o-mini"; @@ -83,6 +84,7 @@ public final class GuiConfigurationTemplateFactory { "", "", "", + "", Map.of()); return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty()); } @@ -116,6 +118,7 @@ public final class GuiConfigurationTemplateFactory { MAX_RETRIES_TRANSIENT, MAX_PAGES, MAX_TEXT_CHARACTERS, + DEFAULT_MAX_TITLE_LENGTH, Boolean.toString(false), AiProviderFamily.CLAUDE.getIdentifier(), providerConfigurations); diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java index 21c9932..7790664 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java @@ -23,6 +23,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; * @param maxRetriesTransient transient retry limit as editable text * @param maxPages page limit as editable text * @param maxTextCharacters text limit as editable text + * @param maxTitleLength maximum base-title length as editable text * @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text * @param activeProviderFamily raw value of {@code ai.provider.active} as editable text * @param providerConfigurations provider-specific editor state keyed by provider family @@ -38,6 +39,7 @@ public record GuiConfigurationValues( String maxRetriesTransient, String maxPages, String maxTextCharacters, + String maxTitleLength, String logAiSensitive, String activeProviderFamily, Map providerConfigurations) { @@ -55,6 +57,7 @@ public record GuiConfigurationValues( * @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string * @param maxPages page limit; {@code null} becomes an empty string * @param maxTextCharacters text limit; {@code null} becomes an empty string + * @param maxTitleLength maximum base-title length; {@code null} becomes an empty string * @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string * @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string * @param providerConfigurations provider-specific state map; must not be {@code null} @@ -70,6 +73,7 @@ public record GuiConfigurationValues( maxRetriesTransient = normalizeText(maxRetriesTransient); maxPages = normalizeText(maxPages); maxTextCharacters = normalizeText(maxTextCharacters); + maxTitleLength = normalizeText(maxTitleLength); logAiSensitive = normalizeText(logAiSensitive); activeProviderFamily = normalizeText(activeProviderFamily); @@ -98,7 +102,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withActiveProviderFamily(String providerFamily) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, providerFamily, providerConfigurations); + maxTitleLength, logAiSensitive, providerFamily, providerConfigurations); } /** @@ -110,7 +114,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withSourceFolder(String value) { return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -122,7 +126,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withTargetFolder(String value) { return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -134,7 +138,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withSqliteFile(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -146,7 +150,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withPromptTemplateFile(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -158,7 +162,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withRuntimeLockFile(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -170,7 +174,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withLogDirectory(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -182,7 +186,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withLogLevel(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -194,7 +198,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withMaxRetriesTransient(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -206,7 +210,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withMaxPages(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -218,7 +222,19 @@ public record GuiConfigurationValues( public GuiConfigurationValues withMaxTextCharacters(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); + } + + /** + * Returns a copy with a different maximum base-title length value. + * + * @param value new value; {@code null} becomes an empty string + * @return a new configuration values object with the requested title-length value + */ + public GuiConfigurationValues withMaxTitleLength(String value) { + return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, + runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, + value, logAiSensitive, activeProviderFamily, providerConfigurations); } /** @@ -230,7 +246,7 @@ public record GuiConfigurationValues( public GuiConfigurationValues withLogAiSensitive(String value) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - value, activeProviderFamily, providerConfigurations); + maxTitleLength, value, activeProviderFamily, providerConfigurations); } /** @@ -243,7 +259,7 @@ public record GuiConfigurationValues( Map providerConfigurations) { return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, - logAiSensitive, activeProviderFamily, providerConfigurations); + maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations); } /** diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspaceSaveTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspaceSaveTest.java index ff91dda..a30aecd 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspaceSaveTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspaceSaveTest.java @@ -157,7 +157,7 @@ class GuiConfigurationEditorWorkspaceSaveTest { return new GuiConfigurationValues( "./source", "./target", "./db.sqlite", "./prompt.txt", "./app.lock", "./logs", "INFO", "3", "10", "5000", - "false", "claude", providers); + "60", "false", "claude", providers); } private GuiConfigurationEditorState buildState(GuiConfigurationValues baseline, diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiDirtyStateTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiDirtyStateTest.java index 58db9a7..9787d2e 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiDirtyStateTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiDirtyStateTest.java @@ -174,6 +174,7 @@ class GuiDirtyStateTest { v.maxRetriesTransient(), v.maxPages(), v.maxTextCharacters(), + v.maxTitleLength(), v.logAiSensitive(), v.activeProviderFamily(), v.providerConfigurations()); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java index 165edb6..7fbff76 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java @@ -93,6 +93,8 @@ class GuiEditorFieldBindingTest { "Max pages must match the standard template default"); assertEquals("5000", v.maxTextCharacters(), "Max text characters must match the standard template default"); + assertEquals("60", v.maxTitleLength(), + "Max title length must match the standard template default"); assertEquals("false", v.logAiSensitive(), "log.ai.sensitive must match the standard template default (false)"); }); @@ -422,6 +424,7 @@ class GuiEditorFieldBindingTest { .withMaxRetriesTransient("5") .withMaxPages("20") .withMaxTextCharacters("1000") + .withMaxTitleLength("80") .withLogAiSensitive("true") .withActiveProviderFamily("openai-compatible"); @@ -435,6 +438,7 @@ class GuiEditorFieldBindingTest { assertEquals("5", modified.maxRetriesTransient()); assertEquals("20", modified.maxPages()); assertEquals("1000", modified.maxTextCharacters()); + assertEquals("80", modified.maxTitleLength()); assertEquals("true", modified.logAiSensitive()); assertEquals("openai-compatible", modified.activeProviderFamily()); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java index 69f1f06..35ab23b 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java @@ -431,6 +431,162 @@ class GuiEditorValidationSmokeTest { }); } + // ========================================================================= + // Scenario: max.title.length – validation per value band + // ========================================================================= + + /** + * Smoke test: when the standard template is applied and the title-length field is cleared + * via the {@code withMaxTitleLength("")} copy, the local validation produces an ERROR + * finding for {@code max.title.length}. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void emptyMaxTitleLength_producesFieldFindingError() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + ws.requestNewConfiguration(); + ws.editorState = ws.editorState().withValues( + ws.editorState().values().withMaxTitleLength("")); + ws.validateButton.fire(); + + assertNotNull(ws.lastValidationResult(), + "lastValidationResult must not be null after editing"); + assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"), + "Clearing max.title.length must produce a field finding"); + boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream() + .anyMatch(f -> "max.title.length".equals(f.fieldKey()) + && f.severity() == GuiMessageSeverity.ERROR); + assertTrue(hasErrorForField, + "Empty max.title.length must be an ERROR for this field"); + }); + } + + /** + * Smoke test: a too-small title-length value (below the minimum of 10) produces an ERROR + * finding for the field. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void tooSmallMaxTitleLength_producesFieldFindingError() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + ws.requestNewConfiguration(); + ws.editorState = ws.editorState().withValues( + ws.editorState().values().withMaxTitleLength("5")); + ws.validateButton.fire(); + + assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"), + "Value below minimum must produce a field finding"); + boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream() + .anyMatch(f -> "max.title.length".equals(f.fieldKey()) + && f.severity() == GuiMessageSeverity.ERROR); + assertTrue(hasErrorForField, + "Value below minimum must be an ERROR for this field"); + }); + } + + /** + * Smoke test: a too-large title-length value (above the upper limit of 120) produces an ERROR + * finding for the field. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void tooLargeMaxTitleLength_producesFieldFindingError() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + ws.requestNewConfiguration(); + ws.editorState = ws.editorState().withValues( + ws.editorState().values().withMaxTitleLength("200")); + ws.validateButton.fire(); + + assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"), + "Value above safe maximum must produce a field finding"); + boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream() + .anyMatch(f -> "max.title.length".equals(f.fieldKey()) + && f.severity() == GuiMessageSeverity.ERROR); + assertTrue(hasErrorForField, + "Value above safe maximum must be an ERROR for this field"); + }); + } + + /** + * Smoke test: a value in the lower warning band (10..19) produces a field finding that is + * not marked as ERROR. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void lowWarnMaxTitleLength_producesWarningOnly() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + ws.requestNewConfiguration(); + ws.editorState = ws.editorState().withValues( + ws.editorState().values().withMaxTitleLength("15")); + ws.validateButton.fire(); + + assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"), + "Value in low warn band must produce a field finding"); + boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream() + .anyMatch(f -> "max.title.length".equals(f.fieldKey()) + && f.severity() == GuiMessageSeverity.ERROR); + assertFalse(hasErrorForField, + "Value in low warn band must not produce an ERROR for this field"); + }); + } + + /** + * Smoke test: a value in the upper warning band (100..120) produces a field finding that is + * not marked as ERROR. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void highWarnMaxTitleLength_producesWarningOnly() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + ws.requestNewConfiguration(); + ws.editorState = ws.editorState().withValues( + ws.editorState().values().withMaxTitleLength("110")); + ws.validateButton.fire(); + + assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"), + "Value in high warn band must produce a field finding"); + boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream() + .anyMatch(f -> "max.title.length".equals(f.fieldKey()) + && f.severity() == GuiMessageSeverity.ERROR); + assertFalse(hasErrorForField, + "Value in high warn band must not produce an ERROR for this field"); + }); + } + + /** + * Smoke test: the default template value of 60 produces no finding for the title-length field. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void defaultMaxTitleLength_producesNoFieldFinding() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + ws.requestNewConfiguration(); + + assertNotNull(ws.lastValidationResult(), + "lastValidationResult must not be null after 'Neu'"); + assertFalse(ws.lastValidationResult().hasFieldFindingFor("max.title.length"), + "Default value 60 must not produce a field finding"); + }); + } + // ========================================================================= // Helpers // ========================================================================= @@ -458,6 +614,7 @@ class GuiEditorValidationSmokeTest { + "max.retries.transient=3\n" + "max.pages=10\n" + "max.text.characters=500\n" + + "max.title.length=60\n" + "prompt.template.file=./config/prompt.txt\n"; Files.writeString(path, content, StandardCharsets.UTF_8); } diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java index f5433cf..05889fd 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java @@ -246,7 +246,7 @@ class GuiTechnicalTestCoordinatorSmokeTest { new EditorValidationInput( "claude", "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.absent(), "", "https://api.openai.com", "gpt-4", "30", @@ -282,7 +282,7 @@ class GuiTechnicalTestCoordinatorSmokeTest { currentInput.set(new EditorValidationInput( "", // empty active provider → validation error in block 1 "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.absent(), "", "https://api.openai.com", "gpt-4", "30", @@ -369,7 +369,7 @@ class GuiTechnicalTestCoordinatorSmokeTest { EditorValidationInput blankInput = new EditorValidationInput( "claude", "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "2000", + "3", "10", "2000", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.absent(), "", "https://api.openai.com", "gpt-4", "30", diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java index 9b9d7a6..3f15480 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java @@ -874,6 +874,7 @@ class GuiUnsavedChangesGuardSmokeTest { v.maxRetriesTransient(), v.maxPages(), v.maxTextCharacters(), + v.maxTitleLength(), v.logAiSensitive(), v.activeProviderFamily(), v.providerConfigurations()); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatterTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatterTest.java index 7d06e0b..ae6b03e 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatterTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatterTest.java @@ -166,6 +166,7 @@ class GuiWindowTitleFormatterTest { v.maxRetriesTransient(), v.maxPages(), v.maxTextCharacters(), + v.maxTitleLength(), v.logAiSensitive(), v.activeProviderFamily(), v.providerConfigurations()); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java index b04bcf1..e45af47 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java @@ -19,7 +19,7 @@ class ConfirmationDialogContentTest { @Test void fromPlan_extractsDescriptionsInOrder() { var s1 = new CorrectionSuggestion.CreateDirectory("/path/a", "Zielordner anlegen"); - var s2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen"); + var s2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen", 60); var plan = new CorrectionPlan(List.of(s1, s2)); var content = ConfirmationDialogContent.fromPlan(plan); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactoryTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactoryTest.java new file mode 100644 index 0000000..3d612c9 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactoryTest.java @@ -0,0 +1,78 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link GuiConfigurationEditorStateFactory}. + *

+ * Verifies that loaded properties are correctly mapped into the editor state, with specific + * attention to the {@code max.title.length} property mapping. + */ +class GuiConfigurationEditorStateFactoryTest { + + @Test + void fromPropertiesSnapshot_mapsMaxTitleLengthWhenPresent() { + Properties props = new Properties(); + props.setProperty("source.folder", "./s"); + props.setProperty("target.folder", "./t"); + props.setProperty("sqlite.file", "./db"); + props.setProperty("prompt.template.file", "./p.txt"); + props.setProperty("ai.provider.active", "claude"); + props.setProperty("max.retries.transient", "3"); + props.setProperty("max.pages", "10"); + props.setProperty("max.text.characters", "5000"); + props.setProperty("max.title.length", "80"); + GuiConfigurationFileSnapshot snapshot = + new GuiConfigurationFileSnapshot(Path.of("config/application.properties"), props); + + GuiConfigurationEditorState state = + GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, Optional.empty()); + + assertEquals("80", state.values().maxTitleLength(), + "Loaded max.title.length value must be present in the editor state"); + assertTrue(state.hasLoadedFileSnapshot()); + assertFalse(state.isDirty()); + } + + @Test + void fromPropertiesSnapshot_missingMaxTitleLengthBecomesBlank() { + Properties props = new Properties(); + props.setProperty("source.folder", "./s"); + props.setProperty("target.folder", "./t"); + props.setProperty("ai.provider.active", "claude"); + // max.title.length intentionally omitted + GuiConfigurationFileSnapshot snapshot = + new GuiConfigurationFileSnapshot(Path.of("config/application.properties"), props); + + GuiConfigurationEditorState state = + GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, Optional.empty()); + + assertEquals("", state.values().maxTitleLength(), + "A missing max.title.length property must be mapped to an empty string"); + } + + @Test + void fromPropertiesSnapshot_blankMaxTitleLengthBecomesBlank() { + Properties props = new Properties(); + props.setProperty("source.folder", "./s"); + props.setProperty("target.folder", "./t"); + props.setProperty("ai.provider.active", "claude"); + props.setProperty("max.title.length", " "); + GuiConfigurationFileSnapshot snapshot = + new GuiConfigurationFileSnapshot(Path.of("config/application.properties"), props); + + GuiConfigurationEditorState state = + GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, Optional.empty()); + + assertEquals("", state.values().maxTitleLength(), + "A blank max.title.length property must be trimmed to an empty string"); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java index 9713e75..6110add 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java @@ -24,6 +24,7 @@ class GuiConfigurationEditorStateTest { state.values().maxRetriesTransient(), state.values().maxPages(), state.values().maxTextCharacters(), + state.values().maxTitleLength(), "maybe", "claude-42", state.values().providerConfigurations()); @@ -90,6 +91,7 @@ class GuiConfigurationEditorStateTest { state.values().maxRetriesTransient(), state.values().maxPages(), state.values().maxTextCharacters(), + state.values().maxTitleLength(), "true", "openai-compatible", state.values().providerConfigurations()); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java index 76f6b29..ffac6bb 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java @@ -34,6 +34,7 @@ class GuiConfigurationTemplateFactoryTest { assertEquals("3", values.maxRetriesTransient()); assertEquals("10", values.maxPages()); assertEquals("5000", values.maxTextCharacters()); + assertEquals("60", values.maxTitleLength()); assertEquals("false", values.logAiSensitive()); assertEquals(AiProviderFamily.CLAUDE.getIdentifier(), values.activeProviderFamily()); @@ -69,6 +70,8 @@ class GuiConfigurationTemplateFactoryTest { GuiConfigurationValues values = state.values(); assertEquals("./work/local/source", values.sourceFolder()); assertEquals("./work/local/target", values.targetFolder()); + assertEquals("60", values.maxTitleLength(), + "Standard template must supply the default title-length value"); assertEquals(AiProviderFamily.CLAUDE.getIdentifier(), values.activeProviderFamily()); assertFalse(values.providerConfigurations().isEmpty()); } @@ -93,6 +96,7 @@ class GuiConfigurationTemplateFactoryTest { assertEquals("", values.maxRetriesTransient()); assertEquals("", values.maxPages()); assertEquals("", values.maxTextCharacters()); + assertEquals("", values.maxTitleLength()); assertEquals("", values.logAiSensitive()); assertEquals("", values.activeProviderFamily()); assertTrue(values.providerConfigurations().isEmpty()); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java index a8d7b85..db1c495 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java @@ -28,10 +28,12 @@ class GuiConfigurationValuesTest { "12", "34", "56", + "78", "maybe", "not-a-provider-family", providerConfigurations); + assertEquals("78", values.maxTitleLength()); assertEquals("maybe", values.logAiSensitive()); assertEquals("not-a-provider-family", values.activeProviderFamily()); assertEquals(GuiProviderConfigurationState.blank(), values.providerConfiguration(AiProviderFamily.CLAUDE)); @@ -53,6 +55,7 @@ class GuiConfigurationValuesTest { "12", "34", "56", + "60", "true", AiProviderFamily.CLAUDE.getIdentifier(), providerConfigurations); @@ -62,4 +65,58 @@ class GuiConfigurationValuesTest { assertNotSame(providerConfigurations, values.providerConfigurations()); assertEquals(1, values.providerConfigurations().size()); } + + @Test + void withMaxTitleLength_producesIndependentCopy() { + Map providerConfigurations = new LinkedHashMap<>(); + providerConfigurations.put(AiProviderFamily.CLAUDE, GuiProviderConfigurationState.blank()); + + GuiConfigurationValues original = new GuiConfigurationValues( + "./source", + "./target", + "./config/db.sqlite", + "./config/prompt.txt", + "./config/runtime.lock", + "./logs", + "INFO", + "3", + "10", + "5000", + "60", + "false", + AiProviderFamily.CLAUDE.getIdentifier(), + providerConfigurations); + + GuiConfigurationValues updated = original.withMaxTitleLength("80"); + + assertEquals("80", updated.maxTitleLength()); + assertEquals("60", original.maxTitleLength(), + "Original instance must remain unchanged"); + assertEquals(original.sourceFolder(), updated.sourceFolder(), + "Unrelated fields must be preserved when copying"); + } + + @Test + void nullMaxTitleLengthBecomesEmptyString() { + Map providerConfigurations = new LinkedHashMap<>(); + providerConfigurations.put(AiProviderFamily.CLAUDE, GuiProviderConfigurationState.blank()); + + GuiConfigurationValues values = new GuiConfigurationValues( + "./source", + "./target", + "./config/db.sqlite", + "./config/prompt.txt", + "./config/runtime.lock", + "./logs", + "INFO", + "3", + "10", + "5000", + null, + "false", + AiProviderFamily.CLAUDE.getIdentifier(), + providerConfigurations); + + assertEquals("", values.maxTitleLength()); + } } 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 45a8178..0424aff 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 @@ -168,6 +168,7 @@ public class StartConfigurationValidator { validateMaxRetriesTransient(config.maxRetriesTransient(), errors); validateMaxPages(config.maxPages(), errors); validateMaxTextCharacters(config.maxTextCharacters(), errors); + validateMaxTitleLength(config.maxTitleLength(), errors); } private void validateOptionalPaths(StartConfiguration config, List errors) { @@ -219,6 +220,45 @@ public class StartConfigurationValidator { } } + /** + * Validates the configured maximum base title length. + *

+ * Hard errors (abort startup): + *

    + *
  • {@code value < 10}
  • + *
  • {@code value > 120}
  • + *
+ * Non-blocking warnings (logged but accepted): + *
    + *
  • {@code 10 <= value <= 19}: low-range warning (below the usual minimum)
  • + *
  • {@code 100 <= value <= 120}: high-range warning (filename compatibility with + * encrypted Synology volumes)
  • + *
+ * + * @param value the configured value + * @param errors collector for aggregated error messages + */ + private void validateMaxTitleLength(int value, List errors) { + if (value < 10) { + errors.add("- max.title.length: must be >= 10 (got: " + value + + "). Minimum ist 10 Zeichen."); + return; + } + if (value > 120) { + errors.add("- max.title.length: must be <= 120 (got: " + value + + "). Überschreitet sicheres Limit für verschlüsselte Synology-Volumes."); + return; + } + if (value <= 19) { + LOG.warn("Titellänge {} unter 20 Zeichen ist für die meisten Dokumente nicht empfohlen", + value); + } else if (value >= 100) { + LOG.warn("Titellänge {} ist hoch – Kompatibilität mit verschlüsselten Volumes " + + "(Limit ~143 Zeichen inkl. Datumspräfix) prüfen", + value); + } + } + private void validatePromptTemplateFile(Path promptTemplateFile, List errors) { validateRequiredRegularFile(promptTemplateFile, "prompt.template.file", errors); } diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java index c8ea919..5c3a189 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapter.java @@ -123,6 +123,7 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort { parseInt(getRequiredProperty(props, "max.retries.transient")), parseInt(getRequiredProperty(props, "max.pages")), parseInt(getRequiredProperty(props, "max.text.characters")), + parseMaxTitleLength(props), Paths.get(getRequiredProperty(props, "prompt.template.file")), Paths.get(getOptionalProperty(props, "runtime.lock.file", "")), Paths.get(getOptionalProperty(props, "log.directory", "")), @@ -169,6 +170,43 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort { } } + /** + * Parses the {@code max.title.length} property. + *

+ * This property controls the maximum length of the base title portion of the generated + * target filename. When the property is absent or blank in the properties file, the + * default value {@value #DEFAULT_MAX_TITLE_LENGTH} is returned for backward compatibility + * with configurations that pre-date this setting. + *

+ * When the property is present but cannot be parsed as an integer, a + * {@link ConfigurationLoadingException} is thrown. The numeric range of the parsed value + * is enforced downstream in the start-configuration validator. + * + * @param props the raw loaded properties; must not be {@code null} + * @return the parsed maximum base title length + * @throws ConfigurationLoadingException if the property is present but cannot be parsed + */ + private int parseMaxTitleLength(Properties props) { + String raw = props.getProperty("max.title.length"); + if (raw == null || raw.isBlank()) { + return DEFAULT_MAX_TITLE_LENGTH; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + throw new ConfigurationLoadingException( + "Invalid integer value for property max.title.length: '" + raw + "'", e); + } + } + + /** + * Default value for {@code max.title.length} when the property is missing or blank. + *

+ * Chosen to preserve the previously hardcoded limit, so existing configurations without + * the property continue to work unchanged. + */ + private static final int DEFAULT_MAX_TITLE_LENGTH = 60; + /** * Parses the {@code log.ai.sensitive} configuration property with strict validation. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java index 67fbbb5..660625c 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java @@ -102,8 +102,10 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort { * stilles Überschreiben). Der Inhalt wird als UTF-8-Text geschrieben. * Die Aktion wird mit Zielpfad geloggt. *

- * Der Inhalt der erzeugten Datei wird von {@link DefaultPromptTemplate#defaultContent()} geliefert. - * Es handelt sich um einen deutschen Standardprompt, der ohne weitere Anpassung funktioniert. + * Der Inhalt der erzeugten Datei wird von {@link DefaultPromptTemplate#defaultContent(int)} + * geliefert. Als Parameter wird die im {@code CreatePromptFile}-Vorschlag enthaltene + * konfigurierte maximale Titellänge verwendet. Es handelt sich um einen deutschen + * Standardprompt, der ohne weitere Anpassung funktioniert. * * @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein * @return Ergebnis der Ausführung; nie {@code null} @@ -131,7 +133,8 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort { LOG.info("Prompt-Datei: Elternordner angelegt: {}", parent); } - Files.writeString(path, DefaultPromptTemplate.defaultContent(), StandardCharsets.UTF_8, + Files.writeString(path, DefaultPromptTemplate.defaultContent(suggestion.maxTitleLength()), + StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); LOG.info("Prompt-Datei erfolgreich erzeugt: {}", path.toAbsolutePath()); return new CorrectionOutcome.Applied(suggestion, 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 index e143001..cec955a 100644 --- 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 @@ -138,14 +138,16 @@ class AnthropicClaudeAdapterIntegrationTest { new FilesystemTargetFileCopyAdapter(targetFolder), noOpLogger, 3, + 60, "claude"); // provider identifier for Claude AiNamingService aiNamingService = new AiNamingService( claudeAdapter, new FilesystemPromptPortAdapter(promptFile), - new AiResponseValidator(new SystemClockAdapter()), + new AiResponseValidator(new SystemClockAdapter(), 60), "claude-3-5-sonnet-20241022", - 10_000); + 10_000, + 60); DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( new RuntimeConfiguration(50, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT), diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java index e55c098..841bc45 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java @@ -50,6 +50,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), @@ -70,6 +71,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, tempDir.resolve("prompt.txt"), null, null, @@ -94,6 +96,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, tempDir.resolve("prompt.txt"), null, null, @@ -118,6 +121,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, tempDir.resolve("prompt.txt"), null, null, @@ -147,6 +151,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -175,6 +180,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, null, null, null, @@ -204,6 +210,7 @@ class StartConfigurationValidatorTest { -1, 100, 50000, + 60, promptTemplateFile, null, null, @@ -233,6 +240,7 @@ class StartConfigurationValidatorTest { 0, 100, 50000, + 60, promptTemplateFile, null, null, @@ -263,6 +271,7 @@ class StartConfigurationValidatorTest { 3, 0, 50000, + 60, promptTemplateFile, null, null, @@ -292,6 +301,7 @@ class StartConfigurationValidatorTest { 3, 100, -1, + 60, promptTemplateFile, null, null, @@ -321,6 +331,7 @@ class StartConfigurationValidatorTest { 1, // maxRetriesTransient = 1 is the minimum valid value 100, 50000, + 60, promptTemplateFile, null, null, @@ -346,6 +357,7 @@ class StartConfigurationValidatorTest { 3, 100, 0, // maxTextCharacters = 0 ist ungültig + 60, promptTemplateFile, null, null, @@ -374,6 +386,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -403,6 +416,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -431,6 +445,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -459,6 +474,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, tempDir.resolve("prompt.txt"), null, null, @@ -489,6 +505,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -517,6 +534,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -545,6 +563,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, tempDir.resolve("nonexistent.txt"), null, null, @@ -574,6 +593,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, dirForPrompt, null, null, @@ -602,6 +622,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -626,6 +647,7 @@ class StartConfigurationValidatorTest { -1, 0, -1, + 60, null, null, null, @@ -662,6 +684,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -695,6 +718,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -728,6 +752,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -762,6 +787,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -793,6 +819,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -824,6 +851,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, null, @@ -853,6 +881,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, tempDir.resolve("nonexistent/lock.lock"), null, @@ -885,6 +914,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, lockFileWithFileAsParent, null, @@ -916,6 +946,7 @@ class StartConfigurationValidatorTest { 3, 100, 50000, + 60, promptTemplateFile, null, logFileInsteadOfDirectory, @@ -929,4 +960,73 @@ class StartConfigurationValidatorTest { ); assertTrue(exception.getMessage().contains("log.directory: exists but is not a directory")); } + + // ========================================================================= + // max.title.length (range checks, warnings logged only) + // ========================================================================= + + @Test + void validate_failsWhenMaxTitleLengthBelowMinimum() throws Exception { + StartConfiguration config = buildValidConfigWithMaxTitleLength(9); + + InvalidStartConfigurationException exception = assertThrows( + InvalidStartConfigurationException.class, + () -> validator.validate(config) + ); + assertTrue(exception.getMessage().contains("max.title.length: must be >= 10")); + } + + @Test + void validate_failsWhenMaxTitleLengthAboveMaximum() throws Exception { + StartConfiguration config = buildValidConfigWithMaxTitleLength(121); + + InvalidStartConfigurationException exception = assertThrows( + InvalidStartConfigurationException.class, + () -> validator.validate(config) + ); + assertTrue(exception.getMessage().contains("max.title.length: must be <= 120")); + } + + @Test + void validate_succeedsForLowWarnRange() throws Exception { + StartConfiguration config = buildValidConfigWithMaxTitleLength(15); + assertDoesNotThrow(() -> validator.validate(config), + "Werte im Bereich 10..19 sind zulässig (nur Warnung im Log)"); + } + + @Test + void validate_succeedsForHighWarnRange() throws Exception { + StartConfiguration config = buildValidConfigWithMaxTitleLength(110); + assertDoesNotThrow(() -> validator.validate(config), + "Werte im Bereich 100..120 sind zulässig (nur Warnung im Log)"); + } + + @Test + void validate_succeedsForNormalRange() throws Exception { + StartConfiguration config = buildValidConfigWithMaxTitleLength(60); + assertDoesNotThrow(() -> validator.validate(config)); + } + + private StartConfiguration buildValidConfigWithMaxTitleLength(int maxTitleLength) throws Exception { + Path sourceFolder = Files.createDirectory(tempDir.resolve("source-" + maxTitleLength)); + Path targetFolder = Files.createDirectory(tempDir.resolve("target-" + maxTitleLength)); + Path sqliteFile = Files.createFile(tempDir.resolve("db-" + maxTitleLength + ".sqlite")); + Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt-" + maxTitleLength + ".txt")); + + return new StartConfiguration( + sourceFolder, + targetFolder, + sqliteFile, + validMultiProviderConfig(), + 3, + 100, + 50000, + maxTitleLength, + promptTemplateFile, + null, + null, + "INFO", + false + ); + } } diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java index 7f0c419..1144734 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java @@ -62,6 +62,8 @@ class PropertiesConfigurationPortAdapterTest { assertEquals(3, config.maxRetriesTransient()); assertEquals(100, config.maxPages()); assertEquals(50000, config.maxTextCharacters()); + // Backward compatibility: missing max.title.length must use default 60 + assertEquals(60, config.maxTitleLength()); assertTrue(config.promptTemplateFile().toString().endsWith("prompt.txt")); assertTrue(config.runtimeLockFile().toString().endsWith("lock.lock")); assertTrue(config.logDirectory().toString().endsWith("logs")); @@ -586,4 +588,86 @@ class PropertiesConfigurationPortAdapterTest { } return configFile; } + + // ========================================================================= + // max.title.length parsing + // ========================================================================= + + @Test + void loadConfiguration_maxTitleLengthExplicit_readsValue() throws Exception { + String content = """ + source.folder=/tmp/source + target.folder=/tmp/target + sqlite.file=/tmp/db.sqlite + ai.provider.active=openai-compatible + ai.provider.openai-compatible.baseUrl=https://api.example.com + ai.provider.openai-compatible.model=gpt-4 + ai.provider.openai-compatible.timeoutSeconds=30 + ai.provider.openai-compatible.apiKey=test-key + max.retries.transient=3 + max.pages=100 + max.text.characters=50000 + max.title.length=80 + prompt.template.file=/tmp/prompt.txt + """; + Path configFile = createInlineConfig(content); + + PropertiesConfigurationPortAdapter adapter = + new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); + var config = adapter.loadConfiguration(); + + assertEquals(80, config.maxTitleLength()); + } + + @Test + void loadConfiguration_maxTitleLengthBlank_usesDefault() throws Exception { + String content = """ + source.folder=/tmp/source + target.folder=/tmp/target + sqlite.file=/tmp/db.sqlite + ai.provider.active=openai-compatible + ai.provider.openai-compatible.baseUrl=https://api.example.com + ai.provider.openai-compatible.model=gpt-4 + ai.provider.openai-compatible.timeoutSeconds=30 + ai.provider.openai-compatible.apiKey=test-key + max.retries.transient=3 + max.pages=100 + max.text.characters=50000 + max.title.length= + prompt.template.file=/tmp/prompt.txt + """; + Path configFile = createInlineConfig(content); + + PropertiesConfigurationPortAdapter adapter = + new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); + var config = adapter.loadConfiguration(); + + assertEquals(60, config.maxTitleLength(), + "Blank max.title.length must fall back to the default 60 (backward compatibility)"); + } + + @Test + void loadConfiguration_maxTitleLengthNonInteger_throwsConfigurationLoadingException() throws Exception { + String content = """ + source.folder=/tmp/source + target.folder=/tmp/target + sqlite.file=/tmp/db.sqlite + ai.provider.active=openai-compatible + ai.provider.openai-compatible.baseUrl=https://api.example.com + ai.provider.openai-compatible.model=gpt-4 + ai.provider.openai-compatible.timeoutSeconds=30 + ai.provider.openai-compatible.apiKey=test-key + max.retries.transient=3 + max.pages=100 + max.text.characters=50000 + max.title.length=abc + prompt.template.file=/tmp/prompt.txt + """; + Path configFile = createInlineConfig(content); + + PropertiesConfigurationPortAdapter adapter = + new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); + + assertThrows(ConfigurationLoadingException.class, adapter::loadConfiguration); + } } diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java index aea7e20..6a7bbed 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java @@ -122,7 +122,7 @@ class FilesystemResourceCreationAdapterTest { void createPromptFile_nonExistent_createsFileAndReturnsApplied(@TempDir Path tempDir) { Path promptFile = tempDir.resolve("prompt.txt"); CorrectionSuggestion.CreatePromptFile suggestion = - new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen"); + new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen", 60); CorrectionOutcome outcome = adapter.createPromptFile(suggestion); @@ -135,7 +135,7 @@ class FilesystemResourceCreationAdapterTest { Path promptFile = tempDir.resolve("existing_prompt.txt"); Files.createFile(promptFile); CorrectionSuggestion.CreatePromptFile suggestion = - new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Datei vorhanden"); + new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Datei vorhanden", 60); CorrectionOutcome outcome = adapter.createPromptFile(suggestion); @@ -147,7 +147,7 @@ class FilesystemResourceCreationAdapterTest { void createPromptFile_nonExistentParent_createsParentAndFile(@TempDir Path tempDir) { Path promptFile = tempDir.resolve("subdir").resolve("prompt.txt"); CorrectionSuggestion.CreatePromptFile suggestion = - new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt in Unterordner"); + new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt in Unterordner", 60); CorrectionOutcome outcome = adapter.createPromptFile(suggestion); @@ -159,14 +159,14 @@ class FilesystemResourceCreationAdapterTest { void createPromptFile_nonExistent_contentMatchesDefaultPromptTemplate(@TempDir Path tempDir) throws IOException { Path promptFile = tempDir.resolve("prompt.txt"); CorrectionSuggestion.CreatePromptFile suggestion = - new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen"); + new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen", 60); CorrectionOutcome outcome = adapter.createPromptFile(suggestion); assertInstanceOf(CorrectionOutcome.Applied.class, outcome); assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren"); String writtenContent = Files.readString(promptFile, StandardCharsets.UTF_8); - String expectedContent = DefaultPromptTemplate.defaultContent(); + String expectedContent = DefaultPromptTemplate.defaultContent(60); // Der geschriebene Inhalt muss dem deutschen Standard-Prompt entsprechen assertTrue(writtenContent.contains("Titel"), "Geschriebener Inhalt muss deutschen Standard-Prompt enthalten"); 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 8ed46fb..2ae46d9 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 @@ -19,6 +19,16 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfi * Exactly one provider family is active per run; the selection is driven by the * {@code ai.provider.active} configuration property. * + *

Maximum base title length ({@code max.title.length})

+ *

+ * The integer property {@code max.title.length} controls the maximum permitted length of + * the base title (title portion without the date prefix and without any duplicate-avoidance + * suffix). The value is supplied to the prompt that is sent to the AI, is enforced when the + * AI response is validated, and is enforced again defensively when the target filename is + * built from a persisted naming proposal. Valid values are integers in the range + * {@code [10, 120]}. When the property is missing or blank, a default of {@code 60} is + * used for backward compatibility with existing configurations. + * *

AI content sensitivity ({@code log.ai.sensitive})

*

* The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated @@ -37,6 +47,7 @@ public record StartConfiguration( int maxRetriesTransient, int maxPages, int maxTextCharacters, + int maxTitleLength, Path promptTemplateFile, Path runtimeLockFile, Path logDirectory, diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java index 08b104e..e85dd48 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java @@ -64,11 +64,18 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; */ public class AiNamingService { + /** + * Placeholder token that can appear in the loaded prompt content and is replaced with + * the configured maximum base title length before the prompt is sent to the AI. + */ + private static final String MAX_TITLE_LENGTH_PLACEHOLDER = "{MAX_TITLE_LENGTH}"; + private final AiInvocationPort aiInvocationPort; private final PromptPort promptPort; private final AiResponseValidator aiResponseValidator; private final String modelName; private final int maxTextCharacters; + private final int maxTitleLength; /** * Creates the AI naming service with all required dependencies. @@ -79,15 +86,21 @@ public class AiNamingService { * @param modelName the AI model name to record in attempt history; must not be null * @param maxTextCharacters the maximum number of document-text characters to send; * must be >= 1 + * @param maxTitleLength the configured maximum base title length; must be >= 1. + * Used to replace the {@value #MAX_TITLE_LENGTH_PLACEHOLDER} + * placeholder in the loaded prompt content before the prompt + * is sent to the AI. * @throws NullPointerException if any reference parameter is null - * @throws IllegalArgumentException if {@code maxTextCharacters} is less than 1 + * @throws IllegalArgumentException if {@code maxTextCharacters} or {@code maxTitleLength} + * is less than 1 */ public AiNamingService( AiInvocationPort aiInvocationPort, PromptPort promptPort, AiResponseValidator aiResponseValidator, String modelName, - int maxTextCharacters) { + int maxTextCharacters, + int maxTitleLength) { this.aiInvocationPort = Objects.requireNonNull(aiInvocationPort, "aiInvocationPort must not be null"); this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null"); this.aiResponseValidator = Objects.requireNonNull(aiResponseValidator, "aiResponseValidator must not be null"); @@ -96,7 +109,12 @@ public class AiNamingService { throw new IllegalArgumentException( "maxTextCharacters must be >= 1, but was: " + maxTextCharacters); } + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, but was: " + maxTitleLength); + } this.maxTextCharacters = maxTextCharacters; + this.maxTitleLength = maxTitleLength; } /** @@ -150,7 +168,11 @@ public class AiNamingService { PromptLoadingSuccess promptSuccess) { String promptIdentifier = promptSuccess.promptIdentifier().identifier(); - String promptContent = promptSuccess.promptContent(); + // Replace the maximum-title-length placeholder with the configured value before the + // prompt is sent to the AI. Prompts that do not contain the placeholder are passed + // through unchanged. + String promptContent = promptSuccess.promptContent() + .replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength)); // Step 2: Limit the document text to the configured maximum String limitedText = DocumentTextLimiter.limit(rawText, maxTextCharacters); diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java index f0b6d5e..536d08a 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java @@ -35,7 +35,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; * an explicit specification that the AI must respond with a JSON object containing * exactly these fields: *

    - *
  • {@code title} — mandatory, max 20 characters (base title)
  • + *
  • {@code title} — mandatory, up to the configured maximum length (base title)
  • *
  • {@code reasoning} — mandatory, the AI's explanation
  • *
  • {@code date} — optional, should be in YYYY-MM-DD format if present
  • *
@@ -102,22 +102,30 @@ public class AiRequestComposer { * This is a helper method that builds the exact string that would be included in the * HTTP request to the AI service. It follows the same deterministic order as * {@link #compose(PromptIdentifier, String, String)}, including the explicit - * JSON-only response format specification. + * JSON-only response format specification with the configured maximum base title + * length. * * @param promptIdentifier the stable identifier for this prompt; must not be null * @param promptContent the prompt template content; must not be null * @param documentText the extracted document text; must not be null + * @param maxTitleLength the configured maximum base title length; must be >= 1 * @return the complete, deterministically-ordered request text for the AI (includes JSON format spec) * @throws NullPointerException if any parameter is null + * @throws IllegalArgumentException if {@code maxTitleLength} is less than 1 */ public static String buildCompleteRequestText( PromptIdentifier promptIdentifier, String promptContent, - String documentText) { + String documentText, + int maxTitleLength) { Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null"); Objects.requireNonNull(promptContent, "promptContent must not be null"); Objects.requireNonNull(documentText, "documentText must not be null"); + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, but was: " + maxTitleLength); + } StringBuilder requestBuilder = new StringBuilder(); requestBuilder.append(promptContent); @@ -128,7 +136,7 @@ public class AiRequestComposer { requestBuilder.append("\n"); requestBuilder.append(documentText); requestBuilder.append("\n"); - appendJsonResponseFormat(requestBuilder); + appendJsonResponseFormat(requestBuilder, maxTitleLength); return requestBuilder.toString(); } @@ -142,14 +150,17 @@ public class AiRequestComposer { * This specification is part of the deterministic composition and is included in * the actual request text sent to the AI service. * - * @param requestBuilder the StringBuilder to append the format specification to + * @param requestBuilder the StringBuilder to append the format specification to + * @param maxTitleLength the configured maximum base title length; must be >= 1 */ - private static void appendJsonResponseFormat(StringBuilder requestBuilder) { + private static void appendJsonResponseFormat(StringBuilder requestBuilder, int maxTitleLength) { requestBuilder.append("--- Response Format (JSON-only) ---"); requestBuilder.append("\n"); requestBuilder.append("Respond with a JSON object containing exactly:"); requestBuilder.append("\n"); - requestBuilder.append(" \"title\": string (mandatory, max 20 characters, base title only)"); + requestBuilder.append(" \"title\": string (mandatory, max ") + .append(maxTitleLength) + .append(" characters, base title only)"); requestBuilder.append("\n"); requestBuilder.append(" \"reasoning\": string (mandatory, explanation of the decision)"); requestBuilder.append("\n"); diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java index e6c1b31..574f2d6 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java @@ -23,7 +23,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse; * *

Title rules (objective)

*
    - *
  • Base title must not exceed 60 characters.
  • + *
  • Base title must not exceed the configured maximum length.
  • *
  • Title must not contain characters other than letters, digits, and space * (Umlauts and ß are permitted).
  • *
  • Title must not be a generic placeholder (e.g., "Dokument", "Datei", "Scan", @@ -60,15 +60,24 @@ public final class AiResponseValidator { ); private final ClockPort clockPort; + private final int maxTitleLength; /** - * Creates the validator with the given clock for date fallback. + * Creates the validator with the given clock for date fallback and the configured + * maximum base title length. * - * @param clockPort the clock for current-date fallback; must not be null - * @throws NullPointerException if {@code clockPort} is null + * @param clockPort the clock for current-date fallback; must not be null + * @param maxTitleLength the configured maximum length for the base title; must be >= 1 + * @throws NullPointerException if {@code clockPort} is null + * @throws IllegalArgumentException if {@code maxTitleLength} is less than 1 */ - public AiResponseValidator(ClockPort clockPort) { + public AiResponseValidator(ClockPort clockPort, int maxTitleLength) { this.clockPort = Objects.requireNonNull(clockPort, "clockPort must not be null"); + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, but was: " + maxTitleLength); + } + this.maxTitleLength = maxTitleLength; } /** @@ -85,9 +94,9 @@ public final class AiResponseValidator { // --- Title validation --- String title = parsed.title().trim(); - if (title.length() > 60) { + if (title.length() > maxTitleLength) { return AiValidationResult.invalid( - "Title exceeds 60 characters (base title): '" + title + "'", + "Title exceeds " + maxTitleLength + " characters (base title): '" + title + "'", AiErrorClassification.FUNCTIONAL); } 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 d04b896..549bb5f 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 @@ -155,17 +155,24 @@ public class DocumentProcessingCoordinator { private final TargetFileCopyPort targetFileCopyPort; private final ProcessingLogger logger; private final int maxRetriesTransient; + private final int maxTitleLength; private final String activeProviderIdentifier; /** * Creates the document processing coordinator with all required ports, logger, - * the transient retry limit, and the active AI provider identifier. + * the transient retry limit, the configured maximum base title length, 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 maxTitleLength} is the configured maximum length for the base title portion + * of the generated target filename. The value is forwarded to + * {@link TargetFilenameBuildingService} when the target filename is built from a + * persisted naming proposal. 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, @@ -184,11 +191,13 @@ 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 maxTitleLength configured maximum base title length; 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, or - * if {@code activeProviderIdentifier} is blank + * @throws IllegalArgumentException if {@code maxRetriesTransient} or {@code maxTitleLength} + * is less than 1, or if {@code activeProviderIdentifier} + * is blank */ public DocumentProcessingCoordinator( DocumentRecordRepository documentRecordRepository, @@ -198,11 +207,16 @@ public class DocumentProcessingCoordinator { TargetFileCopyPort targetFileCopyPort, ProcessingLogger logger, int maxRetriesTransient, + int maxTitleLength, String activeProviderIdentifier) { if (maxRetriesTransient < 1) { throw new IllegalArgumentException( "maxRetriesTransient must be >= 1, got: " + maxRetriesTransient); } + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, got: " + maxTitleLength); + } Objects.requireNonNull(activeProviderIdentifier, "activeProviderIdentifier must not be null"); if (activeProviderIdentifier.isBlank()) { throw new IllegalArgumentException("activeProviderIdentifier must not be blank"); @@ -219,6 +233,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.maxTitleLength = maxTitleLength; this.activeProviderIdentifier = activeProviderIdentifier; } @@ -425,7 +440,7 @@ public class DocumentProcessingCoordinator { // --- Step 2: Build base filename from the proposal --- TargetFilenameBuildingService.BaseFilenameResult filenameResult = - TargetFilenameBuildingService.buildBaseFilename(proposalAttempt); + TargetFilenameBuildingService.buildBaseFilename(proposalAttempt, maxTitleLength); if (filenameResult instanceof TargetFilenameBuildingService.InconsistentProposalState inconsistent) { logger.error("Inconsistent proposal state for '{}': {}", diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java index 49d5420..de41c2a 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java @@ -45,7 +45,7 @@ public final class TargetFilenameBuildingService { // ------------------------------------------------------------------------- /** - * Sealed result of {@link #buildBaseFilename(ProcessingAttempt)}. + * Sealed result of {@link #buildBaseFilename(ProcessingAttempt, int)}. */ public sealed interface BaseFilenameResult permits BaseFilenameReady, InconsistentProposalState { @@ -91,7 +91,8 @@ public final class TargetFilenameBuildingService { *

      *
    • Resolved date must be non-null.
    • *
    • Validated title must be non-null and non-blank.
    • - *
    • Validated title must not exceed 60 characters (before Windows cleaning).
    • + *
    • Validated title must not exceed the configured maximum length + * (before Windows cleaning).
    • *
    • After Windows-character cleaning, title must contain only letters, digits, and spaces.
    • *
    * If any rule is violated, the state is treated as an @@ -100,18 +101,24 @@ public final class TargetFilenameBuildingService { * Windows compatibility: Windows-incompatible characters * (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation. * This ensures the resulting filename can be created on Windows systems. - * The 60-character rule is applied to the original title before cleaning. + * The maximum-length rule is applied to the original title before cleaning. *

    - * The 60-character limit applies exclusively to the base title. A duplicate-avoidance + * The configured maximum length applies exclusively to the base title. A duplicate-avoidance * suffix (e.g., {@code (1)}) may be appended by the target folder adapter after this - * method returns and is not counted against the 60 characters. + * method returns and is not counted against the maximum. * * @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null + * @param maxTitleLength the configured maximum base title length; must be >= 1 * @return a {@link BaseFilenameReady} with the complete filename, or an * {@link InconsistentProposalState} describing the consistency violation + * @throws IllegalArgumentException if {@code maxTitleLength} is less than 1 */ - public static BaseFilenameResult buildBaseFilename(ProcessingAttempt proposalAttempt) { + public static BaseFilenameResult buildBaseFilename(ProcessingAttempt proposalAttempt, int maxTitleLength) { Objects.requireNonNull(proposalAttempt, "proposalAttempt must not be null"); + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, but was: " + maxTitleLength); + } LocalDate date = proposalAttempt.resolvedDate(); String title = proposalAttempt.validatedTitle(); @@ -126,10 +133,10 @@ public final class TargetFilenameBuildingService { "Leading PROPOSAL_READY attempt has no validated title"); } - if (title.length() > 60) { + if (title.length() > maxTitleLength) { return new InconsistentProposalState( - "Leading PROPOSAL_READY attempt has title exceeding 60 characters: '" - + title + "'"); + "Leading PROPOSAL_READY attempt has title exceeding " + maxTitleLength + + " characters: '" + title + "'"); } // Remove Windows-incompatible characters to enable technical Windows compatibility diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidator.java index 16782d8..3762f69 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidator.java @@ -49,6 +49,7 @@ public class EditorConfigurationValidator { static final String FIELD_MAX_RETRIES = "max.retries.transient"; static final String FIELD_MAX_PAGES = "max.pages"; static final String FIELD_MAX_CHARS = "max.text.characters"; + static final String FIELD_MAX_TITLE_LENGTH = "max.title.length"; static final String FIELD_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl"; static final String FIELD_CLAUDE_MODEL = "ai.provider.claude.model"; @@ -64,6 +65,12 @@ public class EditorConfigurationValidator { private static final int MAX_CHARS_STRONG_WARNING_THRESHOLD = 3_000; private static final int MAX_PAGES_HINT_THRESHOLD = 100; + // Grenzen für die maximale Basistitel-Länge. + private static final int TITLE_LENGTH_MIN = 10; + private static final int TITLE_LENGTH_MAX = 120; + private static final int TITLE_LENGTH_LOW_WARN_THRESHOLD = 20; + private static final int TITLE_LENGTH_HIGH_WARN_THRESHOLD = 100; + /** * Erstellt eine neue Instanz des Validators. *

    @@ -149,6 +156,7 @@ public class EditorConfigurationValidator { validateMaxRetriesTransient(input.maxRetriesTransient(), findings); validateMaxPages(input.maxPages(), findings); validateMaxTextCharacters(input.maxTextCharacters(), findings); + validateMaxTitleLength(input.maxTitleLength(), findings); } private void validateMaxRetriesTransient(String rawValue, List findings) { @@ -219,6 +227,67 @@ public class EditorConfigurationValidator { } } + /** + * Validiert den Rohwert für die maximale Basistitel-Länge. + *

    + * Regeln: + *

      + *
    • leer: Fehler
    • + *
    • nicht als ganze Zahl parsebar: Fehler
    • + *
    • < {@value #TITLE_LENGTH_MIN}: Fehler (Minimum)
    • + *
    • > {@value #TITLE_LENGTH_MAX}: Fehler (sicheres Maximum für verschlüsselte Volumes)
    • + *
    • {@value #TITLE_LENGTH_MIN}–{@value #TITLE_LENGTH_LOW_WARN_THRESHOLD} (einschließlich + * 19): Warnung (unter 20 Zeichen selten empfehlenswert)
    • + *
    • ≥ {@value #TITLE_LENGTH_HIGH_WARN_THRESHOLD}: Warnung (Kompatibilitätsrisiko + * mit verschlüsselten Volumes)
    • + *
    • andernfalls (20–99): kein Befund
    • + *
    + * + * @param rawValue Rohwert aus dem Editor; nie {@code null} + * @param findings Zielliste für neue Befunde + */ + private void validateMaxTitleLength(String rawValue, List findings) { + if (rawValue.isBlank()) { + findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH, + "Maximale Titellänge darf nicht leer sein.")); + return; + } + int value; + try { + value = Integer.parseInt(rawValue.strip()); + } catch (NumberFormatException e) { + findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH, + "Maximale Titellänge muss eine ganze Zahl sein.")); + return; + } + + if (value < TITLE_LENGTH_MIN) { + findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH, + "Maximale Titellänge muss mindestens " + TITLE_LENGTH_MIN + + " sein (aktuell: " + value + ").")); + return; + } + if (value > TITLE_LENGTH_MAX) { + findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH, + "Maximale Titellänge überschreitet sicheres Limit von " + TITLE_LENGTH_MAX + + " Zeichen (aktuell: " + value + ").")); + return; + } + if (value < TITLE_LENGTH_LOW_WARN_THRESHOLD) { + findings.add(EditorValidationFinding.warning(FIELD_MAX_TITLE_LENGTH, + "Titellänge unter " + TITLE_LENGTH_LOW_WARN_THRESHOLD + + " Zeichen ist für die meisten Dokumente nicht empfohlen (aktuell: " + + value + ").")); + return; + } + if (value >= TITLE_LENGTH_HIGH_WARN_THRESHOLD) { + findings.add(EditorValidationFinding.warning(FIELD_MAX_TITLE_LENGTH, + "Hohe Titellänge: Kompatibilität mit verschlüsselten Volumes prüfen " + + "(aktuell: " + value + ").")); + } + // 20–99: unkritisch, kein Befund + } + // ========================================================================= // Aktiver Provider – providerabhängige Felder // ========================================================================= diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java index 46c0db1..7e38eab 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorValidationInput.java @@ -24,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApi * @param maxRetriesTransient Rohtextwert von {@code max.retries.transient} * @param maxPages Rohtextwert von {@code max.pages} * @param maxTextCharacters Rohtextwert von {@code max.text.characters} + * @param maxTitleLength Rohtextwert von {@code max.title.length} * @param claudeBaseUrl Rohtextwert der Claude-Basis-URL * @param claudeModel Rohtextwert des Claude-Modellnamens * @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts @@ -44,6 +45,7 @@ public record EditorValidationInput( String maxRetriesTransient, String maxPages, String maxTextCharacters, + String maxTitleLength, String claudeBaseUrl, String claudeModel, String claudeTimeoutSeconds, @@ -66,6 +68,7 @@ public record EditorValidationInput( * @param maxRetriesTransient max. transiente Retries; {@code null} wird zu leerem String * @param maxPages max. Seitenzahl; {@code null} wird zu leerem String * @param maxTextCharacters max. Zeichenzahl; {@code null} wird zu leerem String + * @param maxTitleLength max. Titellänge; {@code null} wird zu leerem String * @param claudeBaseUrl Claude-Basis-URL; {@code null} wird zu leerem String * @param claudeModel Claude-Modellname; {@code null} wird zu leerem String * @param claudeTimeoutSeconds Claude-Timeout; {@code null} wird zu leerem String @@ -87,6 +90,7 @@ public record EditorValidationInput( maxRetriesTransient = normalizeText(maxRetriesTransient); maxPages = normalizeText(maxPages); maxTextCharacters = normalizeText(maxTextCharacters); + maxTitleLength = normalizeText(maxTitleLength); claudeBaseUrl = normalizeText(claudeBaseUrl); claudeModel = normalizeText(claudeModel); claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds); diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java index afcff9e..d10ddfa 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java @@ -67,21 +67,30 @@ public sealed interface CorrectionSuggestion * Die Erzeugung erfolgt nur, wenn der Zielpfad beschreibbar ist. Der konkrete * Standardinhalt wird vom {@link ResourceCreationPort} bereitgestellt. Der * Standardpfad liegt im selben Ordner wie die {@code .properties}-Datei. + *

    + * Der {@code maxTitleLength} wird vom Adapter als Platzhalterwert in den erzeugten + * Standardinhalt eingesetzt, damit der erzeugte Standard-Prompt inhaltlich zu der + * konfigurierten maximalen Titellänge passt. * * @param path Pfad der anzulegenden Prompt-Datei als String; nie {@code null} * @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null} + * @param maxTitleLength konfigurierte maximale Titellänge, die im erzeugten + * Standardinhalt verwendet wird; muss {@code >= 1} sein */ record CreatePromptFile( String path, - String descriptionForUser) implements CorrectionSuggestion { + String descriptionForUser, + int maxTitleLength) implements CorrectionSuggestion { /** * Erstellt einen Vorschlag zum Erzeugen einer deutschen Standard-Prompt-Datei. * * @param path Pfad der Prompt-Datei; darf nicht {@code null} oder leer sein * @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein - * @throws NullPointerException wenn ein Parameter {@code null} ist - * @throws IllegalArgumentException wenn {@code path} leer ist + * @param maxTitleLength konfigurierte maximale Titellänge; muss {@code >= 1} sein + * @throws NullPointerException wenn {@code path} oder {@code descriptionForUser} {@code null} ist + * @throws IllegalArgumentException wenn {@code path} leer ist oder + * {@code maxTitleLength < 1} */ public CreatePromptFile { Objects.requireNonNull(path, "path must not be null"); @@ -89,6 +98,10 @@ public sealed interface CorrectionSuggestion if (path.isBlank()) { throw new IllegalArgumentException("path must not be blank"); } + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, got: " + maxTitleLength); + } } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java index 8417db2..e57e5d4 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java @@ -17,6 +17,13 @@ package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; */ public final class DefaultPromptTemplate { + /** + * Platzhalter für die konfigurierte maximale Titellänge. Wird vor der Rückgabe durch den + * konkreten numerischen Wert ersetzt, damit die erzeugte Prompt-Datei inhaltlich zu der + * konfigurierten maximalen Titellänge passt. + */ + private static final String MAX_TITLE_LENGTH_PLACEHOLDER = "{MAX_TITLE_LENGTH}"; + private DefaultPromptTemplate() { // Utility-Klasse – keine Instanziierung } @@ -28,18 +35,28 @@ public final class DefaultPromptTemplate { *

      *
    • Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)
    • *
    • Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}
    • - *
    • Benennungsregeln für Titel (maximal 20 Zeichen, deutsch, keine Sonderzeichen)
    • + *
    • Benennungsregeln für Titel (maximal {@code maxTitleLength} Zeichen, deutsch, keine Sonderzeichen)
    • *
    • Hinweis auf das Datumsformat ({@code YYYY-MM-DD})
    • *
    *

    * Der Text enthält keinen Platzhalter für den Dokumentinhalt. Der Dokumenttext * wird vom {@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer} - * separat angehängt. + * separat angehängt. Der Standardinhalt enthält zusätzlich den Platzhalter + * {@value #MAX_TITLE_LENGTH_PLACEHOLDER}, damit die zur Laufzeit bestimmte Titellänge + * auch beim erneuten Laden der Datei durch den {@code AiNamingService} korrekt ersetzt + * werden kann. * + * @param maxTitleLength die konfigurierte maximale Titellänge, die in den erzeugten + * Standardtext eingesetzt wird; muss {@code >= 1} sein * @return der deutsche Standard-Prompt-Inhalt; nie {@code null}, nie leer + * @throws IllegalArgumentException wenn {@code maxTitleLength < 1} */ - public static String defaultContent() { - return """ + public static String defaultContent(int maxTitleLength) { + if (maxTitleLength < 1) { + throw new IllegalArgumentException( + "maxTitleLength must be >= 1, got: " + maxTitleLength); + } + String template = """ Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem. Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen. @@ -57,12 +74,13 @@ public final class DefaultPromptTemplate { - Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden. - Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15). - Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt. - - Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix). + - Der Titel hat maximal {MAX_TITLE_LENGTH} Zeichen (Basistitel ohne Suffix). - Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF". - Keine Sonderzeichen außer Leerzeichen im Titel. - Eigennamen bleiben unverändert. - Umlaute und ß sind erlaubt. - Kein Text außerhalb des JSON-Objekts. """; + return template.replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength)); } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java index 39abe7d..fbc29f3 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java @@ -236,7 +236,8 @@ public class TechnicalTestOrchestrator { String configFilePath) { try { List results = new ArrayList<>(4); - results.add(checkPromptFile(input.promptTemplateFile(), configFilePath)); + results.add(checkPromptFile(input.promptTemplateFile(), configFilePath, + resolveMaxTitleLengthForPromptCreation(input.maxTitleLength()))); results.add(checkSourceFolder(input.sourceFolder())); results.add(checkTargetFolder(input.targetFolder())); results.add(checkSqlitePath(input.sqliteFile())); @@ -271,11 +272,14 @@ public class TechnicalTestOrchestrator { * angeboten. Ist der Elternordner nicht beschreibbar, wird eine Failure ohne Korrekturvorschlag * zurückgegeben, aber mit einem Hinweis, die Datei manuell anzulegen. * - * @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein - * @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen + * @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein + * @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen + * @param maxTitleLength konfigurierte maximale Titellänge für den Standardinhalt der + * Prompt-Datei; muss {@code >= 1} sein * @return Prüfpunkt-Ergebnis */ - private CheckpointResult checkPromptFile(String configuredPath, String configFilePath) { + private CheckpointResult checkPromptFile(String configuredPath, String configFilePath, + int maxTitleLength) { // Effektiven Prompt-Pfad bestimmen String effectivePath = resolvePromptPath(configuredPath, configFilePath); @@ -292,7 +296,8 @@ public class TechnicalTestOrchestrator { if (parentWritable) { // Elternordner beschreibbar → Korrekturvorschlag anbieten CorrectionSuggestion suggestion = new CorrectionSuggestion.CreatePromptFile( - effectivePath, "Prompt-Datei anlegen: " + effectivePath); + effectivePath, "Prompt-Datei anlegen: " + effectivePath, + maxTitleLength); return CheckpointResult.Failure.withCorrection( CheckpointId.PROMPT_FILE_PRESENT, CheckpointSeverity.ERROR, @@ -308,6 +313,41 @@ public class TechnicalTestOrchestrator { } } + /** + * Ermittelt aus dem Editor-Rohwert einen für die Prompt-Erzeugung nutzbaren Wert für + * die maximale Titellänge. + *

    + * Diese Methode ist bewusst tolerant: die eigentliche Validierung des Editorwerts + * findet im lokalen Validierungsblock statt und erzeugt dort gegebenenfalls eigene + * Befunde. Für die Standard-Prompt-Erzeugung wird ein plausibler Wert ausgewählt: + *

      + *
    • Wenn der Rohwert eine positive Ganzzahl ist, wird dieser Wert verwendet.
    • + *
    • Andernfalls (leer, nicht parsebar, oder {@code < 1}) wird der projektweite + * Fallback-Wert {@value #DEFAULT_MAX_TITLE_LENGTH_FOR_PROMPT} verwendet, der + * dem historischen Standardwert entspricht.
    • + *
    + * + * @param rawValue Rohwert aus {@link EditorValidationInput#maxTitleLength()}; nie {@code null} + * @return zu verwendender Wert für die Prompt-Standardinhalt-Erzeugung; immer {@code >= 1} + */ + private static int resolveMaxTitleLengthForPromptCreation(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return DEFAULT_MAX_TITLE_LENGTH_FOR_PROMPT; + } + try { + int parsed = Integer.parseInt(rawValue.strip()); + return parsed >= 1 ? parsed : DEFAULT_MAX_TITLE_LENGTH_FOR_PROMPT; + } catch (NumberFormatException e) { + return DEFAULT_MAX_TITLE_LENGTH_FOR_PROMPT; + } + } + + /** + * Projektweiter Fallback-Wert für die maximale Titellänge bei der Prompt-Standardinhalt- + * Erzeugung, identisch mit dem Default bei fehlendem Property in der Konfigurationsdatei. + */ + private static final int DEFAULT_MAX_TITLE_LENGTH_FOR_PROMPT = 60; + /** * Bestimmt den effektiven Prompt-Pfad aus dem konfigurierten Pfad und dem Konfigurationsdateipfad. *

    diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java index 1dedf23..adc7595 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java @@ -46,6 +46,7 @@ class AiNamingServiceTest { private static final String MODEL_NAME = "gpt-4"; private static final int MAX_CHARS = 1000; + private static final int TEST_MAX_TITLE_LENGTH = 60; private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z"); @Mock @@ -62,8 +63,9 @@ class AiNamingServiceTest { @BeforeEach void setUp() { - validator = new AiResponseValidator(() -> FIXED_INSTANT); - service = new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, MAX_CHARS); + validator = new AiResponseValidator(() -> FIXED_INSTANT, TEST_MAX_TITLE_LENGTH); + service = new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, MAX_CHARS, + TEST_MAX_TITLE_LENGTH); candidate = new SourceDocumentCandidate( "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); @@ -294,35 +296,47 @@ class AiNamingServiceTest { @Test void constructor_nullAiPort_throwsNullPointerException() { - assertThatThrownBy(() -> new AiNamingService(null, promptPort, validator, MODEL_NAME, MAX_CHARS)) + assertThatThrownBy(() -> new AiNamingService(null, promptPort, validator, MODEL_NAME, MAX_CHARS, + TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class); } @Test void constructor_nullPromptPort_throwsNullPointerException() { - assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, null, validator, MODEL_NAME, MAX_CHARS)) + assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, null, validator, MODEL_NAME, MAX_CHARS, + TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class); } @Test void constructor_nullValidator_throwsNullPointerException() { - assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, null, MODEL_NAME, MAX_CHARS)) + assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, null, MODEL_NAME, MAX_CHARS, + TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class); } @Test void constructor_maxTextCharactersZero_throwsIllegalArgumentException() { - assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 0)) + assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 0, + TEST_MAX_TITLE_LENGTH)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("maxTextCharacters must be >= 1"); } + @Test + void constructor_maxTitleLengthZero_throwsIllegalArgumentException() { + assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, + MAX_CHARS, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxTitleLength must be >= 1"); + } + @Test void constructor_maxTextCharactersOne_doesNotThrow() { // maxTextCharacters=1 is the minimum valid value (boundary test). // A changed-conditional-boundary mutation that changes '< 1' to '<= 1' would // cause this constructor call to throw — this test detects that mutation. - new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 1); + new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 1, TEST_MAX_TITLE_LENGTH); // No exception expected; reaching this line means the boundary is correct } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java index cdcbe89..5daef12 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java @@ -13,6 +13,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; */ class AiRequestComposerTest { + private static final int TEST_MAX_TITLE_LENGTH = 60; + @Test void compose_shouldCreateAiRequestRepresentation() { // Given @@ -95,7 +97,7 @@ class AiRequestComposerTest { String documentText = "Document content here"; // When - String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, TEST_MAX_TITLE_LENGTH); // Then // Verify deterministic order: prompt, then identifier, then document text @@ -127,7 +129,7 @@ class AiRequestComposerTest { String documentText = "Document"; // When - String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, TEST_MAX_TITLE_LENGTH); // Then assertThat(result).contains("--- Prompt-ID:"); @@ -139,7 +141,7 @@ class AiRequestComposerTest { void buildCompleteRequestText_shouldThrowNullPointerException_whenPromptIdentifierIsNull() { // When & Then assertThatThrownBy( - () -> AiRequestComposer.buildCompleteRequestText(null, "Prompt", "Document")) + () -> AiRequestComposer.buildCompleteRequestText(null, "Prompt", "Document", TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class) .hasMessage("promptIdentifier must not be null"); } @@ -148,7 +150,7 @@ class AiRequestComposerTest { void buildCompleteRequestText_shouldThrowNullPointerException_whenPromptContentIsNull() { // When & Then assertThatThrownBy( - () -> AiRequestComposer.buildCompleteRequestText(new PromptIdentifier("id"), null, "Document")) + () -> AiRequestComposer.buildCompleteRequestText(new PromptIdentifier("id"), null, "Document", TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class) .hasMessage("promptContent must not be null"); } @@ -157,7 +159,7 @@ class AiRequestComposerTest { void buildCompleteRequestText_shouldThrowNullPointerException_whenDocumentTextIsNull() { // When & Then assertThatThrownBy( - () -> AiRequestComposer.buildCompleteRequestText(new PromptIdentifier("id"), "Prompt", null)) + () -> AiRequestComposer.buildCompleteRequestText(new PromptIdentifier("id"), "Prompt", null, TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class) .hasMessage("documentText must not be null"); } @@ -186,13 +188,36 @@ class AiRequestComposerTest { String documentText = "Document\ntext"; // When - String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, TEST_MAX_TITLE_LENGTH); // Then assertThat(result).contains("Prompt\nwith\nnewlines"); assertThat(result).contains("Document\ntext"); } + @Test + void buildCompleteRequestText_shouldIncludeConfiguredMaxTitleLength() { + // Given + PromptIdentifier promptId = new PromptIdentifier("prompt.txt"); + String promptContent = "Analyze the document."; + String documentText = "Sample document content"; + + // When + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, 42); + + // Then + assertThat(result).contains("max 42 characters"); + } + + @Test + void buildCompleteRequestText_shouldRejectZeroMaxTitleLength() { + PromptIdentifier promptId = new PromptIdentifier("prompt.txt"); + assertThatThrownBy( + () -> AiRequestComposer.buildCompleteRequestText(promptId, "Prompt", "Document", 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxTitleLength must be >= 1"); + } + @Test void buildCompleteRequestText_shouldIncludeJsonResponseFormat() { // Given @@ -201,7 +226,7 @@ class AiRequestComposerTest { String documentText = "Sample document content"; // When - String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, TEST_MAX_TITLE_LENGTH); // Then // Verify that the actual composed request includes JSON response format specification @@ -222,7 +247,7 @@ class AiRequestComposerTest { String documentText = "Document to process"; // When - String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, TEST_MAX_TITLE_LENGTH); // Then // Verify deterministic order: document text comes before JSON response format @@ -246,7 +271,7 @@ class AiRequestComposerTest { String documentText = "Content to classify"; // When - String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText, TEST_MAX_TITLE_LENGTH); // Then // Verify all sections are present in the actual composed request diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java index 5271daa..ddbbc82 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java @@ -25,13 +25,14 @@ class AiResponseValidatorTest { private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z"); private static final LocalDate FIXED_DATE = FIXED_INSTANT.atZone(ZoneOffset.UTC).toLocalDate(); + private static final int TEST_MAX_TITLE_LENGTH = 60; private AiResponseValidator validator; @BeforeEach void setUp() { ClockPort fixedClock = () -> FIXED_INSTANT; - validator = new AiResponseValidator(fixedClock); + validator = new AiResponseValidator(fixedClock, TEST_MAX_TITLE_LENGTH); } // ------------------------------------------------------------------------- @@ -230,8 +231,30 @@ class AiResponseValidatorTest { @Test void constructor_nullClockPort_throwsNullPointerException() { - assertThatThrownBy(() -> new AiResponseValidator(null)) + assertThatThrownBy(() -> new AiResponseValidator(null, TEST_MAX_TITLE_LENGTH)) .isInstanceOf(NullPointerException.class) .hasMessage("clockPort must not be null"); } + + @Test + void constructor_zeroMaxTitleLength_throwsIllegalArgumentException() { + ClockPort fixedClock = () -> FIXED_INSTANT; + assertThatThrownBy(() -> new AiResponseValidator(fixedClock, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxTitleLength must be >= 1"); + } + + @Test + void validate_titleExceedsConfiguredMax_returnsInvalidWithConfiguredLimitInMessage() { + ClockPort fixedClock = () -> FIXED_INSTANT; + AiResponseValidator strictValidator = new AiResponseValidator(fixedClock, 20); + String title = "1234567890123456789012345"; // 25 chars > 20 + ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null); + + AiResponseValidator.AiValidationResult result = strictValidator.validate(parsed); + + assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class); + assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage()) + .contains("20"); + } } 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 13bd857..3d1beaa 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 @@ -78,6 +78,9 @@ class DocumentProcessingCoordinatorTest { /** Default transient retry limit used in the shared {@link #processor} instance. */ private static final int DEFAULT_MAX_RETRIES_TRANSIENT = 3; + /** Default base title length used across all coordinator constructions in this test. */ + private static final int TEST_MAX_TITLE_LENGTH = 60; + private CapturingDocumentRecordRepository recordRepo; private CapturingProcessingAttemptRepository attemptRepo; private CapturingUnitOfWorkPort unitOfWorkPort; @@ -95,7 +98,7 @@ class DocumentProcessingCoordinatorTest { unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo); processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); candidate = new SourceDocumentCandidate( "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); @@ -272,7 +275,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); DocumentProcessingOutcome outcome = new TechnicalDocumentError( @@ -704,7 +707,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null)); DocumentProcessingOutcome outcome = new PreCheckPassed( candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); @@ -722,7 +725,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); DocumentProcessingOutcome outcome = new PreCheckPassed( @@ -741,7 +744,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)); recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord)); DocumentProcessingOutcome outcome = new PreCheckFailed( @@ -760,7 +763,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); DocumentProcessingOutcome outcome = new PreCheckPassed( candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); @@ -778,7 +781,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); unitOfWorkPort.failOnExecute = true; DocumentProcessingOutcome outcome = new PreCheckPassed( @@ -797,7 +800,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); DocumentProcessingOutcome outcome = new PreCheckPassed( @@ -816,7 +819,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); unitOfWorkPort.failOnExecute = true; @@ -908,7 +911,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); boolean result = coordinatorWithFailingFolder.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -930,7 +933,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); boolean result = coordinatorWithFailingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1019,7 +1022,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); boolean result = coordinatorWithCountingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> { @@ -1053,7 +1056,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); boolean result = coordinatorWithCountingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1084,7 +1087,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); boolean result = coordinatorWith1Retry.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1119,7 +1122,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1145,7 +1148,8 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger, - 1 /* maxRetriesTransient=1 → immediately final */, "openai-compatible"); + 1 /* maxRetriesTransient=1 → immediately final */, TEST_MAX_TITLE_LENGTH, + "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1168,7 +1172,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCountingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1238,7 +1242,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); DocumentProcessingOutcome transientError = new TechnicalDocumentError(candidate, "Timeout", null); // Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1 @@ -1534,7 +1538,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new DocumentTerminalSuccess( buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()))); @@ -1555,7 +1559,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new DocumentTerminalFinalFailure( buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)))); @@ -1576,7 +1580,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); coordinatorWithCapturing.process(candidate, fingerprint, @@ -1599,7 +1603,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "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)))); @@ -1635,7 +1639,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1660,7 +1664,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1679,7 +1683,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1698,7 +1702,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1720,7 +1724,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1742,7 +1746,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), bothFail, capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1763,7 +1767,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1883,7 +1887,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1913,7 +1917,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1937,7 +1941,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); + DEFAULT_MAX_RETRIES_TRANSIENT, TEST_MAX_TITLE_LENGTH, "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 ef8d763..405f7aa 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 @@ -30,6 +30,7 @@ class TargetFilenameBuildingServiceTest { private static final DocumentFingerprint FINGERPRINT = new DocumentFingerprint("a".repeat(64)); private static final RunId RUN_ID = new RunId("run-test"); + private static final int TEST_MAX_TITLE_LENGTH = 60; // ------------------------------------------------------------------------- // Null guard @@ -38,7 +39,28 @@ class TargetFilenameBuildingServiceTest { @Test void buildBaseFilename_rejectsNullAttempt() { assertThatNullPointerException() - .isThrownBy(() -> TargetFilenameBuildingService.buildBaseFilename(null)); + .isThrownBy(() -> TargetFilenameBuildingService.buildBaseFilename(null, TEST_MAX_TITLE_LENGTH)); + } + + @Test + void buildBaseFilename_rejectsZeroMaxTitleLength() { + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung"); + org.assertj.core.api.Assertions + .assertThatIllegalArgumentException() + .isThrownBy(() -> TargetFilenameBuildingService.buildBaseFilename(attempt, 0)) + .withMessageContaining("maxTitleLength must be >= 1"); + } + + @Test + void buildBaseFilename_titleExceedsConfiguredMax_reportsConfiguredLimit() { + String title = "1234567890123456789012345"; // 25 chars + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); + + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, 20); + + assertThat(result).isInstanceOf(InconsistentProposalState.class); + assertThat(((InconsistentProposalState) result).reason()) + .contains("exceeding 20 characters"); } // ------------------------------------------------------------------------- @@ -49,7 +71,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_validProposal_returnsCorrectFormat() { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 15), "Rechnung"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -60,7 +82,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_dateWithLeadingZeros_formatsCorrectly() { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 5), "Kontoauszug"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -71,7 +93,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_titleWithDigits_isAccepted() { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 6, 1), "Rechnung 2026"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -82,7 +104,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_titleWithGermanUmlauts_isAccepted() { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 4, 7), "Strom Abr"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); } @@ -92,7 +114,7 @@ class TargetFilenameBuildingServiceTest { // ä, ö, ü, ß are Unicode letters and must be accepted ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 4, 7), "Büroausgabe"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -104,7 +126,7 @@ class TargetFilenameBuildingServiceTest { String title = "A".repeat(60); // exactly 60 characters ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); } @@ -119,7 +141,7 @@ class TargetFilenameBuildingServiceTest { String title = "Stromabrechnung 2026"; // 20 chars (well within 60-char limit) ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); String filename = ((BaseFilenameReady) result).baseFilename(); @@ -136,7 +158,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_nullDate_returnsInconsistentProposalState() { ProcessingAttempt attempt = proposalAttempt(null, "Rechnung"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(((InconsistentProposalState) result).reason()) @@ -151,7 +173,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_nullTitle_returnsInconsistentProposalState() { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), null); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(((InconsistentProposalState) result).reason()) @@ -162,7 +184,7 @@ class TargetFilenameBuildingServiceTest { void buildBaseFilename_blankTitle_returnsInconsistentProposalState() { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), " "); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(((InconsistentProposalState) result).reason()) @@ -178,7 +200,7 @@ class TargetFilenameBuildingServiceTest { String title = "A".repeat(61); // 61 characters ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(((InconsistentProposalState) result).reason()) @@ -194,7 +216,7 @@ class TargetFilenameBuildingServiceTest { // Hyphens are not letters, digits, or spaces — disallowed by fachliche Titelregel ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung-2026"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(((InconsistentProposalState) result).reason()) @@ -207,7 +229,7 @@ class TargetFilenameBuildingServiceTest { // leaving "RgStrom" which is valid (letters only) ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rg/Strom"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -220,7 +242,7 @@ class TargetFilenameBuildingServiceTest { // leaving "Rechnung 2026" which is valid (letters, digits, and spaces) ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung: \"2026\""); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -233,7 +255,7 @@ class TargetFilenameBuildingServiceTest { // So it remains in the cleaned title and causes validation to fail ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung.pdf"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(InconsistentProposalState.class); } @@ -249,7 +271,7 @@ class TargetFilenameBuildingServiceTest { // correct Windows-compatible filenames ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 5, 20), "Versicherung"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -262,7 +284,7 @@ class TargetFilenameBuildingServiceTest { // and must be retained in the output filename ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 6, 15), "Überprüfung"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -274,7 +296,7 @@ class TargetFilenameBuildingServiceTest { // German ß is a valid filename character on Windows and must be retained ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 10), "Straße"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -287,7 +309,7 @@ class TargetFilenameBuildingServiceTest { // The hyphen in the date and the dot in the extension are valid Windows characters ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 12, 31), "Bericht"); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); String filename = ((BaseFilenameReady) result).baseFilename(); @@ -311,7 +333,7 @@ class TargetFilenameBuildingServiceTest { String title = "Stromabrechnung 2026"; // 20 characters (within 60-char limit) ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title); - BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(result).isInstanceOf(BaseFilenameReady.class); assertThat(((BaseFilenameReady) result).baseFilename()) @@ -327,7 +349,7 @@ class TargetFilenameBuildingServiceTest { ProcessingAttempt attempt = proposalAttempt(null, "Rechnung"); InconsistentProposalState state = - (InconsistentProposalState) TargetFilenameBuildingService.buildBaseFilename(attempt); + (InconsistentProposalState) TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(state.reason()).isNotNull(); } @@ -341,7 +363,7 @@ class TargetFilenameBuildingServiceTest { ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 7, 4), "Bescheid"); BaseFilenameReady ready = - (BaseFilenameReady) TargetFilenameBuildingService.buildBaseFilename(attempt); + (BaseFilenameReady) TargetFilenameBuildingService.buildBaseFilename(attempt, TEST_MAX_TITLE_LENGTH); assertThat(ready.baseFilename()).isNotNull().isNotBlank(); } 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 33c777b..51931d1 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 @@ -82,6 +82,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; */ class BatchRunProcessingUseCaseTest { + private static final int TEST_MAX_TITLE_LENGTH = 60; + @TempDir Path tempDir; @@ -469,7 +471,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator( new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), - new NoOpProcessingLogger(), 3, "openai-compatible") { + new NoOpProcessingLogger(), 3, TEST_MAX_TITLE_LENGTH, "openai-compatible") { @Override public boolean processDeferredOutcome( de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, @@ -517,7 +519,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator( new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), - new NoOpProcessingLogger(), 3, "openai-compatible") { + new NoOpProcessingLogger(), 3, TEST_MAX_TITLE_LENGTH, "openai-compatible") { private int callCount = 0; @Override @@ -761,7 +763,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWork, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); // Fingerprint port returns the pre-defined fingerprint for this candidate FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint); @@ -809,7 +811,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWork, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint); @@ -863,7 +865,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWork, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); FingerprintPort perCandidateFingerprintPort = candidate -> { if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp); @@ -980,8 +982,9 @@ class BatchRunProcessingUseCaseTest { PromptPort stubPromptPort = () -> new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content"); ClockPort stubClock = () -> java.time.Instant.EPOCH; - AiResponseValidator validator = new AiResponseValidator(stubClock); - return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000); + AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH); + return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000, + TEST_MAX_TITLE_LENGTH); } private static DefaultBatchRunProcessingUseCase buildUseCase( @@ -1156,7 +1159,7 @@ class BatchRunProcessingUseCaseTest { NoOpDocumentProcessingCoordinator() { super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); } } @@ -1169,7 +1172,7 @@ class BatchRunProcessingUseCaseTest { TrackingDocumentProcessingCoordinator() { super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, - "openai-compatible"); + TEST_MAX_TITLE_LENGTH, "openai-compatible"); } @Override diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java index 11f3c62..31ec4be 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/editor/EditorConfigurationValidatorTest.java @@ -37,6 +37,7 @@ class EditorConfigurationValidatorTest { "3", // maxRetriesTransient "10", // maxPages "500", // maxTextCharacters + "60", // maxTitleLength "https://api.anthropic.com", // claudeBaseUrl "claude-3-5-sonnet", // claudeModel "30", // claudeTimeoutSeconds @@ -58,7 +59,7 @@ class EditorConfigurationValidatorTest { void validate_emptyActiveProvider_producesError() { EditorValidationInput input = new EditorValidationInput( "", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "", "", "30", EffectiveApiKeyDescriptor.absent(), "", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -75,7 +76,7 @@ class EditorConfigurationValidatorTest { void validate_unknownActiveProvider_producesError() { EditorValidationInput input = new EditorValidationInput( "unknown-provider", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "", "", "30", EffectiveApiKeyDescriptor.absent(), "", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -94,7 +95,7 @@ class EditorConfigurationValidatorTest { void validate_emptySourceFolder_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -109,7 +110,7 @@ class EditorConfigurationValidatorTest { void validate_emptyTargetFolder_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -124,7 +125,7 @@ class EditorConfigurationValidatorTest { void validate_emptySqliteFile_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -139,7 +140,7 @@ class EditorConfigurationValidatorTest { void validate_emptyPromptFile_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -158,7 +159,7 @@ class EditorConfigurationValidatorTest { void validate_maxRetriesTransient_zero_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "0", "10", "500", + "0", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -173,7 +174,7 @@ class EditorConfigurationValidatorTest { void validate_maxRetriesTransient_negative_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "-1", "10", "500", + "-1", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -188,7 +189,7 @@ class EditorConfigurationValidatorTest { void validate_maxRetriesTransient_one_producesNoError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "1", "10", "500", + "1", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -204,7 +205,7 @@ class EditorConfigurationValidatorTest { void validate_maxRetriesTransient_nonNumeric_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "abc", "10", "500", + "abc", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -223,7 +224,7 @@ class EditorConfigurationValidatorTest { void validate_maxPages_zero_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "0", "500", + "3", "0", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -238,7 +239,7 @@ class EditorConfigurationValidatorTest { void validate_maxPages_over100_producesHint() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "101", "500", + "3", "101", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -253,7 +254,7 @@ class EditorConfigurationValidatorTest { void validate_maxPages_exactly100_producesNoHintAndNoError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "100", "500", + "3", "100", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -275,7 +276,7 @@ class EditorConfigurationValidatorTest { void validate_maxTextCharacters_1000_producesNoFinding() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "1000", + "3", "10", "1000", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -290,7 +291,7 @@ class EditorConfigurationValidatorTest { void validate_maxTextCharacters_1001_producesWarning() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "1001", + "3", "10", "1001", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -305,7 +306,7 @@ class EditorConfigurationValidatorTest { void validate_maxTextCharacters_3000_producesWarning() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "3000", + "3", "10", "3000", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -320,7 +321,7 @@ class EditorConfigurationValidatorTest { void validate_maxTextCharacters_3001_producesStrongWarning() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "3001", + "3", "10", "3001", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -340,7 +341,7 @@ class EditorConfigurationValidatorTest { void validate_maxTextCharacters_zero_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "0", + "3", "10", "0", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -359,7 +360,7 @@ class EditorConfigurationValidatorTest { void validate_claude_emptyModel_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -374,7 +375,7 @@ class EditorConfigurationValidatorTest { void validate_claude_emptyBaseUrl_producesWarning() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -389,7 +390,7 @@ class EditorConfigurationValidatorTest { void validate_claude_negativeTimeout_producesError() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "-5", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -408,7 +409,7 @@ class EditorConfigurationValidatorTest { void validate_claude_absent_apiKey_producesWarning() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.absent(), "", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -434,7 +435,7 @@ class EditorConfigurationValidatorTest { void validate_claude_fromEnvVar_producesInfoFinding() { EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"), "", "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); @@ -450,7 +451,7 @@ class EditorConfigurationValidatorTest { void validate_openai_fromLegacyEnvVar_producesInfoFinding() { EditorValidationInput input = new EditorValidationInput( "openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "", "", "30", EffectiveApiKeyDescriptor.absent(), "", "https://api.openai.com", "gpt-4", "30", EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"), ""); @@ -479,7 +480,7 @@ class EditorConfigurationValidatorTest { void validate_fullyValidOpenAiConfig_producesNoErrors() { EditorValidationInput input = new EditorValidationInput( "openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "", "", "30", EffectiveApiKeyDescriptor.absent(), "", "https://api.openai.com", "gpt-4", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-openai"); @@ -498,7 +499,7 @@ class EditorConfigurationValidatorTest { // Claude ist aktiv; OpenAI-Felder sind leer – darf keinen FEHLER für OpenAI-Felder geben EditorValidationInput input = new EditorValidationInput( "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-5-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", "", "", "", EffectiveApiKeyDescriptor.absent(), ""); @@ -511,4 +512,97 @@ class EditorConfigurationValidatorTest { assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_TIMEOUT)) .noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR); } + + // ========================================================================= + // max.title.length + // ========================================================================= + + private static EditorValidationInput inputWithMaxTitleLength(String rawMaxTitleLength) { + return new EditorValidationInput( + "claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt", + "3", "10", "500", rawMaxTitleLength, + "https://api.anthropic.com", "claude-3-5-sonnet", "30", + EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-claude", + "", "", "30", EffectiveApiKeyDescriptor.absent(), ""); + } + + @Test + void validate_maxTitleLength_empty_producesError() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR); + } + + @Test + void validate_maxTitleLength_nonInteger_producesError() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("abc")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR); + } + + @Test + void validate_maxTitleLength_tooSmall_producesError() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("5")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR); + } + + @Test + void validate_maxTitleLength_nine_producesError() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("9")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR); + } + + @Test + void validate_maxTitleLength_tooLarge_producesError() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("121")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR); + } + + @Test + void validate_maxTitleLength_lowWarnRange_producesWarning() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("15")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING); + } + + @Test + void validate_maxTitleLength_highWarnRange_producesWarning() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("110")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING); + } + + @Test + void validate_maxTitleLength_sixty_producesNoFinding() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("60")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .isEmpty(); + } + + @Test + void validate_maxTitleLength_twenty_producesNoFinding() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("20")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .isEmpty(); + } + + @Test + void validate_maxTitleLength_ninetyNine_producesNoFinding() { + EditorValidationReport report = validator.validate(inputWithMaxTitleLength("99")); + + assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_TITLE_LENGTH)) + .isEmpty(); + } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java index 274b7c2..60cbf90 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java @@ -16,7 +16,7 @@ class CorrectionExecutionReportTest { private final CorrectionSuggestion s1 = new CorrectionSuggestion.CreateDirectory("/path/a", "Ordner A"); private final CorrectionSuggestion s2 = - new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt erzeugen"); + new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt erzeugen", 60); @Test void emptyReport_allAppliedIsFalse() { diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java index 5516c83..4808bf0 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java @@ -80,7 +80,7 @@ class CorrectionExecutionServiceTest { CorrectionSuggestion.CreateDirectory dir = new CorrectionSuggestion.CreateDirectory("C:/foo", "Zielordner anlegen"); CorrectionSuggestion.CreatePromptFile prompt = - new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen"); + new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen", 60); CorrectionSuggestion.PrepareSqlitePath sqlite = new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten"); @@ -99,7 +99,7 @@ class CorrectionExecutionServiceTest { CorrectionSuggestion.CreateDirectory dir = new CorrectionSuggestion.CreateDirectory("C:/foo", "Ordner anlegen"); CorrectionSuggestion.CreatePromptFile prompt = - new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen"); + new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen", 60); CorrectionSuggestion.PrepareSqlitePath sqlite = new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten"); @@ -143,9 +143,9 @@ class CorrectionExecutionServiceTest { /** * Der {@link CorrectionExecutionService} dispatcht {@link CorrectionSuggestion.CreatePromptFile} * an den Port. Ein Port-Stub, der den Inhalt der Suggestion zurückgibt, muss den - * deutschen Standardinhalt aus {@link DefaultPromptTemplate#defaultContent()} enthalten, + * deutschen Standardinhalt aus {@link DefaultPromptTemplate#defaultContent(int)} enthalten, * wenn der Adapter ihn korrekt befüllt. Hier prüfen wir lediglich, dass - * {@link DefaultPromptTemplate#defaultContent()} einen sinnvollen deutschen Text liefert, + * {@link DefaultPromptTemplate#defaultContent(int)} einen sinnvollen deutschen Text liefert, * der für die Dispatch-Kette geeignet ist. */ @Test @@ -153,9 +153,9 @@ class CorrectionExecutionServiceTest { // Der Dispatch selbst ist im Service zustandslos. // Wir prüfen hier, dass DefaultPromptTemplate den benötigten Inhalt liefert, // damit der Adapter ihn verwenden kann. - String content = DefaultPromptTemplate.defaultContent(); + String content = DefaultPromptTemplate.defaultContent(60); assertNotNull(content); - assertFalse(content.isBlank(), "DefaultPromptTemplate.defaultContent() darf nicht leer sein"); + assertFalse(content.isBlank(), "DefaultPromptTemplate.defaultContent(60) darf nicht leer sein"); assertTrue(content.contains("Titel"), "Inhalt muss deutsches Schlüsselwort 'Titel' enthalten"); assertTrue(content.contains("date"), "Inhalt muss JSON-Feld 'date' beschreiben"); assertTrue(content.contains("reasoning"), "Inhalt muss JSON-Feld 'reasoning' beschreiben"); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java index d5a4964..fd59503 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java @@ -34,7 +34,7 @@ class CorrectionPlanTest { var mutable = new ArrayList(); mutable.add(new CorrectionSuggestion.CreateDirectory("/a", "d1")); var plan = new CorrectionPlan(mutable); - mutable.add(new CorrectionSuggestion.CreatePromptFile("/b", "d2")); + mutable.add(new CorrectionSuggestion.CreatePromptFile("/b", "d2", 60)); assertThat(plan.suggestions()).hasSize(1); } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java index 21d5a83..c184f3e 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java @@ -36,15 +36,22 @@ class CorrectionSuggestionTest { @Test void createPromptFile_storesPathAndDescription() { - var s = new CorrectionSuggestion.CreatePromptFile("/config/prompt.txt", "Prompt-Datei erzeugen"); + var s = new CorrectionSuggestion.CreatePromptFile("/config/prompt.txt", "Prompt-Datei erzeugen", 60); assertThat(s.path()).isEqualTo("/config/prompt.txt"); assertThat(s.descriptionForUser()).isEqualTo("Prompt-Datei erzeugen"); + assertThat(s.maxTitleLength()).isEqualTo(60); } @Test void createPromptFile_blankPathThrows() { assertThatIllegalArgumentException() - .isThrownBy(() -> new CorrectionSuggestion.CreatePromptFile("", "desc")); + .isThrownBy(() -> new CorrectionSuggestion.CreatePromptFile("", "desc", 60)); + } + + @Test + void createPromptFile_zeroMaxTitleLengthThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CorrectionSuggestion.CreatePromptFile("/p", "desc", 0)); } // --- PrepareSqlitePath --- @@ -67,7 +74,7 @@ class CorrectionSuggestionTest { @Test void patternMatching_coversAllPermittedTypes() { CorrectionSuggestion dir = new CorrectionSuggestion.CreateDirectory("/a", "d1"); - CorrectionSuggestion prompt = new CorrectionSuggestion.CreatePromptFile("/b", "d2"); + CorrectionSuggestion prompt = new CorrectionSuggestion.CreatePromptFile("/b", "d2", 60); CorrectionSuggestion sqlite = new CorrectionSuggestion.PrepareSqlitePath("/c", "d3"); assertThat(classify(dir)).isEqualTo("directory"); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java index 50661f3..6b61eb7 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java @@ -1,6 +1,7 @@ package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import org.junit.jupiter.api.Test; @@ -12,16 +13,18 @@ import org.junit.jupiter.api.Test; */ class DefaultPromptTemplateTest { + private static final int TEST_MAX_TITLE_LENGTH = 60; + @Test void defaultContent_isNotNullAndNotEmpty() { - String content = DefaultPromptTemplate.defaultContent(); + String content = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); assertThat(content).isNotNull(); assertThat(content).isNotBlank(); } @Test void defaultContent_containsGermanKeywords() { - String content = DefaultPromptTemplate.defaultContent(); + String content = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); assertThat(content).contains("Titel"); assertThat(content).contains("Datum"); assertThat(content).contains("Deutsch"); @@ -29,7 +32,7 @@ class DefaultPromptTemplateTest { @Test void defaultContent_containsJsonSchemaHint() { - String content = DefaultPromptTemplate.defaultContent(); + String content = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); // JSON-Felder müssen im Prompt beschrieben sein assertThat(content).contains("title"); assertThat(content).contains("reasoning"); @@ -38,21 +41,35 @@ class DefaultPromptTemplateTest { @Test void defaultContent_containsDateFormatHint() { - String content = DefaultPromptTemplate.defaultContent(); + String content = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); assertThat(content).contains("YYYY-MM-DD"); } @Test - void defaultContent_mentionsTitleMaxLength() { - String content = DefaultPromptTemplate.defaultContent(); - assertThat(content).contains("20"); + void defaultContent_mentionsConfiguredTitleMaxLength() { + String content = DefaultPromptTemplate.defaultContent(42); + // Der übergebene Wert muss im Text auftauchen + assertThat(content).contains("42"); + } + + @Test + void defaultContent_doesNotContainPlaceholderAfterReplacement() { + String content = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); + assertThat(content).doesNotContain("{MAX_TITLE_LENGTH}"); } @Test void defaultContent_isConsistent_calledTwice() { - // Idempotenz-Prüfung: zwei Aufrufe liefern denselben Inhalt - String first = DefaultPromptTemplate.defaultContent(); - String second = DefaultPromptTemplate.defaultContent(); + // Idempotenz-Prüfung: zwei Aufrufe mit gleichem Parameter liefern denselben Inhalt + String first = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); + String second = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH); assertThat(first).isEqualTo(second); } + + @Test + void defaultContent_rejectsZeroMaxTitleLength() { + assertThatIllegalArgumentException() + .isThrownBy(() -> DefaultPromptTemplate.defaultContent(0)) + .withMessageContaining("maxTitleLength must be >= 1"); + } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java index 8a504ea..3224ba3 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java @@ -27,7 +27,7 @@ class ProviderTechnicalTestServiceTest { return new EditorValidationInput( "claude", "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "2000", + "3", "10", "2000", "60", "https://api.anthropic.com", model, "30", apiKeyDescriptor, "sk-test", "https://api.openai.com", "gpt-4", "30", @@ -39,7 +39,7 @@ class ProviderTechnicalTestServiceTest { return new EditorValidationInput( "openai-compatible", "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "2000", + "3", "10", "2000", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.absent(), "", "https://api.openai.com", model, "30", @@ -407,7 +407,7 @@ class ProviderTechnicalTestServiceTest { EditorValidationInput input = new EditorValidationInput( "unknown-provider", "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "2000", + "3", "10", "2000", "60", "", "model", "30", EffectiveApiKeyDescriptor.absent(), "", "", "model", "30", diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java index 80aeef9..1c8db0a 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java @@ -29,7 +29,7 @@ class TechnicalTestOrchestratorTest { return new EditorValidationInput( "claude", "/src", "/tgt", "/db.sqlite", "/prompt.txt", - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test", "https://api.openai.com", "gpt-4", "30", @@ -43,7 +43,7 @@ class TechnicalTestOrchestratorTest { return new EditorValidationInput( "", // leerer aktiver Provider → Fehler in Block 1 "", "", "", "", - "", "", "", + "", "", "", "", "", "", "", EffectiveApiKeyDescriptor.absent(), "", "", "", "", @@ -370,7 +370,7 @@ class TechnicalTestOrchestratorTest { "claude", "/src", "/tgt", "/db.sqlite", "", // kein Prompt-Pfad - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test", "https://api.openai.com", "gpt-4", "30", @@ -415,7 +415,7 @@ class TechnicalTestOrchestratorTest { "claude", "/src", "/tgt", "/db.sqlite", "", // kein Prompt-Pfad - "3", "10", "500", + "3", "10", "500", "60", "https://api.anthropic.com", "claude-3-sonnet", "30", EffectiveApiKeyDescriptor.fromPropertyFile(), "sk-test", "https://api.openai.com", "gpt-4", "30", diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java index 71284aa..9081633 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java @@ -61,7 +61,7 @@ class TechnicalTestReportTest { @Test void deriveCorrectionPlan_extractsSuggestionsFromFailures() { var suggestion1 = new CorrectionSuggestion.CreateDirectory("/path/target", "Zielordner anlegen"); - var suggestion2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen"); + var suggestion2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen", 60); var failure1 = CheckpointResult.Failure.withCorrection( CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "fehlt", suggestion1); var failure2 = CheckpointResult.Failure.withCorrection( diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java index 1443caf..b23c09e 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java @@ -15,7 +15,7 @@ class TechnicalTestRequestTest { private static EditorValidationInput minimalInput() { return new EditorValidationInput( - "claude", "", "", "", "", "3", "10", "2000", + "claude", "", "", "", "", "3", "10", "2000", "60", "", "model-x", "60", EffectiveApiKeyDescriptor.absent(), "", "", "", "60", EffectiveApiKeyDescriptor.absent(), ""); } 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 47ea325..f8043ce 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 @@ -349,17 +349,19 @@ public class BootstrapRunner { new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger, startConfig.maxRetriesTransient(), + startConfig.maxTitleLength(), activeFamily.getIdentifier()); PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); ClockPort clockPort = new SystemClockAdapter(); - AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort); + AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength()); AiNamingService aiNamingService = new AiNamingService( aiInvocationPort, promptPort, aiResponseValidator, providerConfig.model(), - startConfig.maxTextCharacters()); + startConfig.maxTextCharacters(), + startConfig.maxTitleLength()); ProcessingLogger useCaseLogger = new Log4jProcessingLogger( DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriter.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriter.java index a746b43..7835101 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriter.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriter.java @@ -51,6 +51,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; * max.retries.transient=... * max.pages=... * max.text.characters=... + * max.title.length=... * prompt.template.file=... * # Logging * log.ai.sensitive=... @@ -200,6 +201,7 @@ public final class GuiConfigurationPropertiesWriter implements GuiConfigurationF appendKeyValue(sb, "max.retries.transient", values.maxRetriesTransient()); appendKeyValue(sb, "max.pages", values.maxPages()); appendKeyValue(sb, "max.text.characters", values.maxTextCharacters()); + appendKeyValue(sb, "max.title.length", values.maxTitleLength()); appendKeyValue(sb, "prompt.template.file", values.promptTemplateFile()); appendLine(sb, ""); diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java index 291f8a4..c51e965 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java @@ -426,7 +426,7 @@ class BootstrapRunnerConfigPathSemanticsTest { tempDir.resolve("target"), sqliteFile, multiConfig, - 3, 100, 50000, + 3, 100, 50000, 60, promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), 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 6231e46..71abecb 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 @@ -70,6 +70,7 @@ class BootstrapRunnerEdgeCasesTest { 3, 100, 50000, + 60, Files.createFile(tempDir.resolve("prompt.txt")), null, // null runtimeLockFile tempDir.resolve("logs"), @@ -114,7 +115,7 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("target")), sqliteFile, validMultiProviderConfig(), - 3, 100, 50000, + 3, 100, 50000, 60, Files.createFile(tempDir.resolve("prompt.txt")), tempDir.resolve("lock.lock"), tempDir.resolve("logs"), @@ -139,7 +140,7 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("target")), sqliteFile, validMultiProviderConfig(), - 3, 100, 50000, + 3, 100, 50000, 60, Files.createFile(tempDir.resolve("prompt.txt")), tempDir.resolve("lock.lock"), tempDir.resolve("logs"), @@ -164,7 +165,7 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("target")), Files.createFile(tempDir.resolve("db.sqlite")), validMultiProviderConfig(), - 3, 100, 50000, + 3, 100, 50000, 60, Files.createFile(tempDir.resolve("prompt.txt")), tempDir.resolve("lock.lock"), tempDir.resolve("logs"), @@ -231,7 +232,7 @@ class BootstrapRunnerEdgeCasesTest { Path dbFile = Files.createFile(tempDir.resolve("db.sqlite")); Path promptFile = Files.createFile(tempDir.resolve("prompt.txt")); return new StartConfiguration(sourceDir, targetDir, dbFile, - validMultiProviderConfig(), 3, 100, 50000, + validMultiProviderConfig(), 3, 100, 50000, 60, promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", false); } catch (Exception e) { @@ -538,6 +539,7 @@ class BootstrapRunnerEdgeCasesTest { 3, 100, 50000, + 60, promptTemplateFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java index 3282d11..a50e86a 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java @@ -223,7 +223,7 @@ class BootstrapRunnerStartupDispatchTest { tempDir.resolve("target"), sqliteFile, multiConfig, - 3, 100, 50000, + 3, 100, 50000, 60, promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), 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 8c5bf59..e02a597 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 @@ -197,6 +197,7 @@ class BootstrapRunnerTest { 3, 100, 50000, + 60, promptFile, Paths.get(""), // empty – simulates unconfigured runtime.lock.file tempDir.resolve("logs"), @@ -280,6 +281,7 @@ class BootstrapRunnerTest { 0, // max.retries.transient = 0 is invalid (must be >= 1) 100, 50000, + 60, promptFile, tempDir.resolve("lock-mrt.lock"), null, @@ -516,6 +518,7 @@ class BootstrapRunnerTest { 3, 100, 50000, + 60, promptTemplateFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), 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 index 47037c9..b279a75 100644 --- 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 @@ -160,6 +160,7 @@ class BootstrapSmokeTest { 3, 10, 5000, + 60, promptFile, tempDir.resolve("run.lock"), tempDir.resolve("logs"), diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java index d750750..4dfcaee 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java @@ -128,6 +128,7 @@ class GuiConfigurationPropertiesWriterTest { assertEquals("3", props.getProperty("max.retries.transient")); assertEquals("10", props.getProperty("max.pages")); assertEquals("5000", props.getProperty("max.text.characters")); + assertEquals("60", props.getProperty("max.title.length")); assertEquals("./prompt.txt", props.getProperty("prompt.template.file")); assertEquals("false", props.getProperty("log.ai.sensitive")); assertEquals("./logs", props.getProperty("log.directory")); @@ -196,6 +197,20 @@ class GuiConfigurationPropertiesWriterTest { assertTrue(logAiPos < lockPos, "Logging section must appear before runtime section"); } + @Test + void buildPropertiesContent_maxTitleLengthAppearsAfterMaxTextCharacters() { + GuiConfigurationValues values = buildTestValues("claude", "", ""); + + String content = writer.buildPropertiesContent(values); + + int maxCharsPos = content.indexOf("max.text.characters="); + int maxTitleLengthPos = content.indexOf("max.title.length="); + assertTrue(maxCharsPos >= 0, "max.text.characters must be present"); + assertTrue(maxTitleLengthPos >= 0, "max.title.length must be present"); + assertTrue(maxCharsPos < maxTitleLengthPos, + "max.title.length must appear after max.text.characters"); + } + @Test void buildPropertiesContent_containsGroupingComments() { GuiConfigurationValues values = buildTestValues("claude", "", ""); @@ -295,6 +310,7 @@ class GuiConfigurationPropertiesWriterTest { "3", "10", "5000", + "60", "false", activeProvider, providerConfigurations); diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java index 6d1f373..49be675 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java @@ -113,6 +113,9 @@ public final class E2ETestContext implements AutoCloseable { */ static final int MAX_RETRIES_TRANSIENT = 3; + /** Default maximum base title length used in the E2E test context. */ + static final int MAX_TITLE_LENGTH = 60; + /** Model name carried in attempt history (no real inference occurs). */ static final String AI_MODEL = "e2e-stub-model"; @@ -400,13 +403,14 @@ public final class E2ETestContext implements AutoCloseable { targetFileCopyPort, coordinatorLogger, MAX_RETRIES_TRANSIENT, + MAX_TITLE_LENGTH, providerIdentifier); PromptPort promptPort = new FilesystemPromptPortAdapter(promptFile); ClockPort clockPort = new SystemClockAdapter(); - AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort); + AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, MAX_TITLE_LENGTH); AiNamingService aiNamingService = new AiNamingService( - aiStub, promptPort, aiResponseValidator, AI_MODEL, MAX_TEXT_CHARS); + aiStub, promptPort, aiResponseValidator, AI_MODEL, MAX_TEXT_CHARS, MAX_TITLE_LENGTH); ProcessingLogger useCaseLogger = new Log4jProcessingLogger( DefaultBatchRunProcessingUseCase.class); diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiFunctionalFailure.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiFunctionalFailure.java index c3a9d29..475fa18 100644 --- a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiFunctionalFailure.java +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiFunctionalFailure.java @@ -8,7 +8,7 @@ import java.util.Objects; * Functional failures occur when the AI returns a structurally valid response but the * content violates the applicable fachliche rules, for example: *

      - *
    • Title exceeds 20 characters
    • + *
    • Title exceeds the configured maximum base title length
    • *
    • Title contains prohibited special characters
    • *
    • Title is a generic placeholder (e.g., "Dokument", "Scan")
    • *
    • AI-provided date is present but not a valid YYYY-MM-DD string
    • diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java index 137100e..f4889df 100644 --- a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java @@ -18,8 +18,8 @@ import java.util.Objects; * or is a fallback to the current system date. *
    • {@link #validatedTitle()} — the title extracted and validated from * the AI response. Guaranteed to be non-null, non-empty, and compliant - * with documented title rules (max 20 base characters, no prohibited - * special characters, etc.).
    • + * with documented title rules (within the configured maximum base title + * length, no prohibited special characters, etc.). *
    • {@link #aiReasoning()} — the justification or explanation provided * by the AI for its proposal. Non-null (may be empty in edge cases, * though normally expected to be meaningful).
    • @@ -39,7 +39,8 @@ import java.util.Objects; * @param dateSource origin of the date ({@link DateSource#AI_PROVIDED} or * {@link DateSource#FALLBACK_CURRENT}); never null * @param validatedTitle the title validated per application rules (non-null, non-empty, - * max 20 base characters as defined in requirements) + * within the configured maximum base title length as defined in + * the application configuration) * @param aiReasoning the AI's explanation for the proposal (non-null, may be empty) */ public record NamingProposal( diff --git a/pdf-umbenenner-packaging/src/main/packaging/application.example.properties b/pdf-umbenenner-packaging/src/main/packaging/application.example.properties index 3ce5f36..5808f78 100644 --- a/pdf-umbenenner-packaging/src/main/packaging/application.example.properties +++ b/pdf-umbenenner-packaging/src/main/packaging/application.example.properties @@ -102,6 +102,12 @@ max.pages=10 # Standardvorlage der GUI: 5000. max.text.characters=5000 +# Maximale Länge des Basistitels in Zeichen (10..120). Default 60. +# Werte unter 10 oder ueber 120 verhindern den Start. +# Werte 10-19: Warnung (fuer die meisten Dokumente nicht empfohlen). +# Werte 100-120: Warnung (Dateiname wird sehr lang, Kompatibilitaet mit verschluesselten Volumes pruefen). +max.title.length=60 + # --------------------------------------------------------------------------- # Optionale Parameter # ---------------------------------------------------------------------------