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
@@ -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,
@@ -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 &gt;= 1
* @param maxTitleLength the configured maximum base title length; must be &gt;= 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);
@@ -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 &gt;= 1
* @return the complete, deterministically-ordered request text for the AI (includes JSON format spec)
* @throws NullPointerException if any parameter is null
* @throws IllegalArgumentException if {@code maxTitleLength} is less than 1
*/
public static String buildCompleteRequestText(
PromptIdentifier promptIdentifier,
String promptContent,
String documentText) {
String documentText,
int maxTitleLength) {
Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null");
Objects.requireNonNull(promptContent, "promptContent must not be null");
Objects.requireNonNull(documentText, "documentText must not be null");
if (maxTitleLength < 1) {
throw new IllegalArgumentException(
"maxTitleLength must be >= 1, but was: " + maxTitleLength);
}
StringBuilder requestBuilder = new StringBuilder();
requestBuilder.append(promptContent);
@@ -128,7 +136,7 @@ public class AiRequestComposer {
requestBuilder.append("\n");
requestBuilder.append(documentText);
requestBuilder.append("\n");
appendJsonResponseFormat(requestBuilder);
appendJsonResponseFormat(requestBuilder, maxTitleLength);
return requestBuilder.toString();
}
@@ -142,14 +150,17 @@ public class AiRequestComposer {
* This specification is part of the deterministic composition and is included in
* the actual request text sent to the AI service.
*
* @param requestBuilder the StringBuilder to append the format specification to
* @param requestBuilder the StringBuilder to append the format specification to
* @param maxTitleLength the configured maximum base title length; must be &gt;= 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");
@@ -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
* @throws NullPointerException if {@code clockPort} is null
* @param clockPort the clock for current-date fallback; must not be null
* @param maxTitleLength the configured maximum length for the base title; must be &gt;= 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);
}
@@ -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 &gt;= 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 &gt;= 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 &gt;= 1
* @param maxTitleLength configured maximum base title length; must be &gt;= 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 '{}': {}",
@@ -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 &gt;= 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
@@ -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>&lt; {@value #TITLE_LENGTH_MIN}: Fehler (Minimum)</li>
* <li>&gt; {@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>&ge; {@value #TITLE_LENGTH_HIGH_WARN_THRESHOLD}: Warnung (Kompatibilitätsrisiko
* mit verschlüsselten Volumes)</li>
* <li>andernfalls (2099): 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 + ")."));
}
// 2099: unkritisch, kein Befund
}
// =========================================================================
// Aktiver Provider providerabhängige Felder
// =========================================================================
@@ -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);
@@ -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);
}
}
}
@@ -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));
}
}
@@ -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()));
@@ -271,11 +272,14 @@ public class TechnicalTestOrchestrator {
* angeboten. Ist der Elternordner nicht beschreibbar, wird eine Failure ohne Korrekturvorschlag
* zurückgegeben, aber mit einem Hinweis, die Datei manuell anzulegen.
*
* @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
* @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
* @param maxTitleLength konfigurierte maximale Titellänge für den Standardinhalt der
* Prompt-Datei; muss {@code >= 1} sein
* @return Prüfpunkt-Ergebnis
*/
private CheckpointResult checkPromptFile(String configuredPath, String configFilePath) {
private CheckpointResult checkPromptFile(String configuredPath, String configFilePath,
int maxTitleLength) {
// Effektiven Prompt-Pfad bestimmen
String effectivePath = resolvePromptPath(configuredPath, configFilePath);
@@ -292,7 +296,8 @@ public class TechnicalTestOrchestrator {
if (parentWritable) {
// Elternordner beschreibbar → Korrekturvorschlag anbieten
CorrectionSuggestion suggestion = new CorrectionSuggestion.CreatePromptFile(
effectivePath, "Prompt-Datei anlegen: " + effectivePath);
effectivePath, "Prompt-Datei anlegen: " + effectivePath,
maxTitleLength);
return CheckpointResult.Failure.withCorrection(
CheckpointId.PROMPT_FILE_PRESENT,
CheckpointSeverity.ERROR,
@@ -308,6 +313,41 @@ public class TechnicalTestOrchestrator {
}
}
/**
* Ermittelt aus dem Editor-Rohwert einen für die Prompt-Erzeugung nutzbaren Wert für
* die maximale Titellänge.
* <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>
@@ -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
}
}
@@ -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,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");
}
}
@@ -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);
@@ -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();
}
@@ -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
@@ -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();
}
}
@@ -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() {
@@ -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");
@@ -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);
}
@@ -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");
@@ -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");
}
}
@@ -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",
@@ -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",
@@ -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(
@@ -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(), "");
}