Titellänge nun parametrisierbar
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 - <Titel>.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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+7
-1
@@ -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(),
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ public final class GuiApiKeyMerger {
|
||||
current.maxRetriesTransient(),
|
||||
current.maxPages(),
|
||||
current.maxTextCharacters(),
|
||||
current.maxTitleLength(),
|
||||
current.logAiSensitive(),
|
||||
current.activeProviderFamily(),
|
||||
merged);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+29
-13
@@ -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<AiProviderFamily, GuiProviderConfigurationState> 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<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -174,6 +174,7 @@ class GuiDirtyStateTest {
|
||||
v.maxRetriesTransient(),
|
||||
v.maxPages(),
|
||||
v.maxTextCharacters(),
|
||||
v.maxTitleLength(),
|
||||
v.logAiSensitive(),
|
||||
v.activeProviderFamily(),
|
||||
v.providerConfigurations());
|
||||
|
||||
+4
@@ -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());
|
||||
|
||||
|
||||
+157
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+1
@@ -874,6 +874,7 @@ class GuiUnsavedChangesGuardSmokeTest {
|
||||
v.maxRetriesTransient(),
|
||||
v.maxPages(),
|
||||
v.maxTextCharacters(),
|
||||
v.maxTitleLength(),
|
||||
v.logAiSensitive(),
|
||||
v.activeProviderFamily(),
|
||||
v.providerConfigurations());
|
||||
|
||||
+1
@@ -166,6 +166,7 @@ class GuiWindowTitleFormatterTest {
|
||||
v.maxRetriesTransient(),
|
||||
v.maxPages(),
|
||||
v.maxTextCharacters(),
|
||||
v.maxTitleLength(),
|
||||
v.logAiSensitive(),
|
||||
v.activeProviderFamily(),
|
||||
v.providerConfigurations());
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+78
@@ -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}.
|
||||
* <p>
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
+2
@@ -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());
|
||||
|
||||
+4
@@ -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());
|
||||
|
||||
+57
@@ -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<AiProviderFamily, GuiProviderConfigurationState> 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<AiProviderFamily, GuiProviderConfigurationState> 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());
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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<String> errors) {
|
||||
@@ -219,6 +220,45 @@ public class StartConfigurationValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the configured maximum base title length.
|
||||
* <p>
|
||||
* Hard errors (abort startup):
|
||||
* <ul>
|
||||
* <li>{@code value < 10}</li>
|
||||
* <li>{@code value > 120}</li>
|
||||
* </ul>
|
||||
* Non-blocking warnings (logged but accepted):
|
||||
* <ul>
|
||||
* <li>{@code 10 <= value <= 19}: low-range warning (below the usual minimum)</li>
|
||||
* <li>{@code 100 <= value <= 120}: high-range warning (filename compatibility with
|
||||
* encrypted Synology volumes)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param value the configured value
|
||||
* @param errors collector for aggregated error messages
|
||||
*/
|
||||
private void validateMaxTitleLength(int value, List<String> 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<String> errors) {
|
||||
validateRequiredRegularFile(promptTemplateFile, "prompt.template.file", errors);
|
||||
}
|
||||
|
||||
+38
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
|
||||
+6
-3
@@ -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.
|
||||
* <p>
|
||||
* 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,
|
||||
|
||||
+4
-2
@@ -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),
|
||||
|
||||
+100
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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");
|
||||
|
||||
+11
@@ -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.
|
||||
*
|
||||
* <h2>Maximum base title length ({@code max.title.length})</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <h2>AI content sensitivity ({@code log.ai.sensitive})</h2>
|
||||
* <p>
|
||||
* 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,
|
||||
|
||||
+25
-3
@@ -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);
|
||||
|
||||
+17
-6
@@ -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:
|
||||
* <ul>
|
||||
* <li>{@code title} — mandatory, max 20 characters (base title)</li>
|
||||
* <li>{@code title} — mandatory, up to the configured maximum length (base title)</li>
|
||||
* <li>{@code reasoning} — mandatory, the AI's explanation</li>
|
||||
* <li>{@code date} — optional, should be in YYYY-MM-DD format if present</li>
|
||||
* </ul>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -143,13 +151,16 @@ public class AiRequestComposer {
|
||||
* the actual request text sent to the AI service.
|
||||
*
|
||||
* @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");
|
||||
|
||||
+14
-5
@@ -23,7 +23,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
|
||||
*
|
||||
* <h3>Title rules (objective)</h3>
|
||||
* <ul>
|
||||
* <li>Base title must not exceed 60 characters.</li>
|
||||
* <li>Base title must not exceed the configured maximum length.</li>
|
||||
* <li>Title must not contain characters other than letters, digits, and space
|
||||
* (Umlauts and ß are permitted).</li>
|
||||
* <li>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
|
||||
* @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);
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -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.
|
||||
* <p>
|
||||
* {@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.
|
||||
* <p>
|
||||
* {@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.
|
||||
* <p>
|
||||
* {@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 '{}': {}",
|
||||
|
||||
+16
-9
@@ -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 {
|
||||
* <ul>
|
||||
* <li>Resolved date must be non-null.</li>
|
||||
* <li>Validated title must be non-null and non-blank.</li>
|
||||
* <li>Validated title must not exceed 60 characters (before Windows cleaning).</li>
|
||||
* <li>Validated title must not exceed the configured maximum length
|
||||
* (before Windows cleaning).</li>
|
||||
* <li>After Windows-character cleaning, title must contain only letters, digits, and spaces.</li>
|
||||
* </ul>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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
|
||||
|
||||
+69
@@ -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.
|
||||
* <p>
|
||||
@@ -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<EditorValidationFinding> findings) {
|
||||
@@ -219,6 +227,67 @@ public class EditorConfigurationValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert den Rohwert für die maximale Basistitel-Länge.
|
||||
* <p>
|
||||
* Regeln:
|
||||
* <ul>
|
||||
* <li>leer: Fehler</li>
|
||||
* <li>nicht als ganze Zahl parsebar: Fehler</li>
|
||||
* <li>< {@value #TITLE_LENGTH_MIN}: Fehler (Minimum)</li>
|
||||
* <li>> {@value #TITLE_LENGTH_MAX}: Fehler (sicheres Maximum für verschlüsselte Volumes)</li>
|
||||
* <li>{@value #TITLE_LENGTH_MIN}–{@value #TITLE_LENGTH_LOW_WARN_THRESHOLD} (einschließlich
|
||||
* 19): Warnung (unter 20 Zeichen selten empfehlenswert)</li>
|
||||
* <li>≥ {@value #TITLE_LENGTH_HIGH_WARN_THRESHOLD}: Warnung (Kompatibilitätsrisiko
|
||||
* mit verschlüsselten Volumes)</li>
|
||||
* <li>andernfalls (20–99): kein Befund</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param rawValue Rohwert aus dem Editor; nie {@code null}
|
||||
* @param findings Zielliste für neue Befunde
|
||||
*/
|
||||
private void validateMaxTitleLength(String rawValue, List<EditorValidationFinding> 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
|
||||
// =========================================================================
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
+16
-3
@@ -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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-5
@@ -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 {
|
||||
* <ul>
|
||||
* <li>Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)</li>
|
||||
* <li>Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}</li>
|
||||
* <li>Benennungsregeln für Titel (maximal 20 Zeichen, deutsch, keine Sonderzeichen)</li>
|
||||
* <li>Benennungsregeln für Titel (maximal {@code maxTitleLength} Zeichen, deutsch, keine Sonderzeichen)</li>
|
||||
* <li>Hinweis auf das Datumsformat ({@code YYYY-MM-DD})</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
+43
-3
@@ -236,7 +236,8 @@ public class TechnicalTestOrchestrator {
|
||||
String configFilePath) {
|
||||
try {
|
||||
List<CheckpointResult> 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()));
|
||||
@@ -273,9 +274,12 @@ public class TechnicalTestOrchestrator {
|
||||
*
|
||||
* @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.
|
||||
* <p>
|
||||
* 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:
|
||||
* <ul>
|
||||
* <li>Wenn der Rohwert eine positive Ganzzahl ist, wird dieser Wert verwendet.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
* <p>
|
||||
|
||||
+21
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+34
-9
@@ -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
|
||||
|
||||
+25
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+36
-32
@@ -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);
|
||||
|
||||
|
||||
+45
-23
@@ -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();
|
||||
}
|
||||
|
||||
+12
-9
@@ -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
|
||||
|
||||
+120
-26
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+6
-6
@@ -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");
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class CorrectionPlanTest {
|
||||
var mutable = new ArrayList<CorrectionSuggestion>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -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");
|
||||
|
||||
+27
-10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+4
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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(), "");
|
||||
}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+2
@@ -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, "");
|
||||
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+6
-4
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+3
@@ -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"),
|
||||
|
||||
+1
@@ -160,6 +160,7 @@ class BootstrapSmokeTest {
|
||||
3,
|
||||
10,
|
||||
5000,
|
||||
60,
|
||||
promptFile,
|
||||
tempDir.resolve("run.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
|
||||
+16
@@ -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);
|
||||
|
||||
+6
-2
@@ -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);
|
||||
|
||||
+1
-1
@@ -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:
|
||||
* <ul>
|
||||
* <li>Title exceeds 20 characters</li>
|
||||
* <li>Title exceeds the configured maximum base title length</li>
|
||||
* <li>Title contains prohibited special characters</li>
|
||||
* <li>Title is a generic placeholder (e.g., "Dokument", "Scan")</li>
|
||||
* <li>AI-provided date is present but not a valid YYYY-MM-DD string</li>
|
||||
|
||||
+4
-3
@@ -18,8 +18,8 @@ import java.util.Objects;
|
||||
* or is a fallback to the current system date.</li>
|
||||
* <li>{@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.).</li>
|
||||
* with documented title rules (within the configured maximum base title
|
||||
* length, no prohibited special characters, etc.).</li>
|
||||
* <li>{@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).</li>
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user