Titellänge nun parametrisierbar

This commit is contained in:
2026-04-22 09:53:03 +02:00
parent 088fd85572
commit 8286d0f0e5
74 changed files with 1450 additions and 236 deletions
@@ -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);
}
@@ -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>
@@ -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,
@@ -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),
@@ -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
);
}
}
@@ -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);
}
}
@@ -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");