Titellänge nun parametrisierbar
This commit is contained in:
+11
@@ -19,6 +19,16 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfi
|
||||
* Exactly one provider family is active per run; the selection is driven by the
|
||||
* {@code ai.provider.active} configuration property.
|
||||
*
|
||||
* <h2>Maximum base title length ({@code max.title.length})</h2>
|
||||
* <p>
|
||||
* The integer property {@code max.title.length} controls the maximum permitted length of
|
||||
* the base title (title portion without the date prefix and without any duplicate-avoidance
|
||||
* suffix). The value is supplied to the prompt that is sent to the AI, is enforced when the
|
||||
* AI response is validated, and is enforced again defensively when the target filename is
|
||||
* built from a persisted naming proposal. Valid values are integers in the range
|
||||
* {@code [10, 120]}. When the property is missing or blank, a default of {@code 60} is
|
||||
* used for backward compatibility with existing configurations.
|
||||
*
|
||||
* <h2>AI content sensitivity ({@code log.ai.sensitive})</h2>
|
||||
* <p>
|
||||
* The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated
|
||||
@@ -37,6 +47,7 @@ public record StartConfiguration(
|
||||
int maxRetriesTransient,
|
||||
int maxPages,
|
||||
int maxTextCharacters,
|
||||
int maxTitleLength,
|
||||
Path promptTemplateFile,
|
||||
Path runtimeLockFile,
|
||||
Path logDirectory,
|
||||
|
||||
+25
-3
@@ -64,11 +64,18 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||
*/
|
||||
public class AiNamingService {
|
||||
|
||||
/**
|
||||
* Placeholder token that can appear in the loaded prompt content and is replaced with
|
||||
* the configured maximum base title length before the prompt is sent to the AI.
|
||||
*/
|
||||
private static final String MAX_TITLE_LENGTH_PLACEHOLDER = "{MAX_TITLE_LENGTH}";
|
||||
|
||||
private final AiInvocationPort aiInvocationPort;
|
||||
private final PromptPort promptPort;
|
||||
private final AiResponseValidator aiResponseValidator;
|
||||
private final String modelName;
|
||||
private final int maxTextCharacters;
|
||||
private final int maxTitleLength;
|
||||
|
||||
/**
|
||||
* Creates the AI naming service with all required dependencies.
|
||||
@@ -79,15 +86,21 @@ public class AiNamingService {
|
||||
* @param modelName the AI model name to record in attempt history; must not be null
|
||||
* @param maxTextCharacters the maximum number of document-text characters to send;
|
||||
* must be >= 1
|
||||
* @param maxTitleLength the configured maximum base title length; must be >= 1.
|
||||
* Used to replace the {@value #MAX_TITLE_LENGTH_PLACEHOLDER}
|
||||
* placeholder in the loaded prompt content before the prompt
|
||||
* is sent to the AI.
|
||||
* @throws NullPointerException if any reference parameter is null
|
||||
* @throws IllegalArgumentException if {@code maxTextCharacters} is less than 1
|
||||
* @throws IllegalArgumentException if {@code maxTextCharacters} or {@code maxTitleLength}
|
||||
* is less than 1
|
||||
*/
|
||||
public AiNamingService(
|
||||
AiInvocationPort aiInvocationPort,
|
||||
PromptPort promptPort,
|
||||
AiResponseValidator aiResponseValidator,
|
||||
String modelName,
|
||||
int maxTextCharacters) {
|
||||
int maxTextCharacters,
|
||||
int maxTitleLength) {
|
||||
this.aiInvocationPort = Objects.requireNonNull(aiInvocationPort, "aiInvocationPort must not be null");
|
||||
this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null");
|
||||
this.aiResponseValidator = Objects.requireNonNull(aiResponseValidator, "aiResponseValidator must not be null");
|
||||
@@ -96,7 +109,12 @@ public class AiNamingService {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTextCharacters must be >= 1, but was: " + maxTextCharacters);
|
||||
}
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, but was: " + maxTitleLength);
|
||||
}
|
||||
this.maxTextCharacters = maxTextCharacters;
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,7 +168,11 @@ public class AiNamingService {
|
||||
PromptLoadingSuccess promptSuccess) {
|
||||
|
||||
String promptIdentifier = promptSuccess.promptIdentifier().identifier();
|
||||
String promptContent = promptSuccess.promptContent();
|
||||
// Replace the maximum-title-length placeholder with the configured value before the
|
||||
// prompt is sent to the AI. Prompts that do not contain the placeholder are passed
|
||||
// through unchanged.
|
||||
String promptContent = promptSuccess.promptContent()
|
||||
.replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength));
|
||||
|
||||
// Step 2: Limit the document text to the configured maximum
|
||||
String limitedText = DocumentTextLimiter.limit(rawText, maxTextCharacters);
|
||||
|
||||
+18
-7
@@ -35,7 +35,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
* an explicit specification that the AI must respond with a JSON object containing
|
||||
* exactly these fields:
|
||||
* <ul>
|
||||
* <li>{@code title} — mandatory, max 20 characters (base title)</li>
|
||||
* <li>{@code title} — mandatory, up to the configured maximum length (base title)</li>
|
||||
* <li>{@code reasoning} — mandatory, the AI's explanation</li>
|
||||
* <li>{@code date} — optional, should be in YYYY-MM-DD format if present</li>
|
||||
* </ul>
|
||||
@@ -102,22 +102,30 @@ public class AiRequestComposer {
|
||||
* This is a helper method that builds the exact string that would be included in the
|
||||
* HTTP request to the AI service. It follows the same deterministic order as
|
||||
* {@link #compose(PromptIdentifier, String, String)}, including the explicit
|
||||
* JSON-only response format specification.
|
||||
* JSON-only response format specification with the configured maximum base title
|
||||
* length.
|
||||
*
|
||||
* @param promptIdentifier the stable identifier for this prompt; must not be null
|
||||
* @param promptContent the prompt template content; must not be null
|
||||
* @param documentText the extracted document text; must not be null
|
||||
* @param maxTitleLength the configured maximum base title length; must be >= 1
|
||||
* @return the complete, deterministically-ordered request text for the AI (includes JSON format spec)
|
||||
* @throws NullPointerException if any parameter is null
|
||||
* @throws IllegalArgumentException if {@code maxTitleLength} is less than 1
|
||||
*/
|
||||
public static String buildCompleteRequestText(
|
||||
PromptIdentifier promptIdentifier,
|
||||
String promptContent,
|
||||
String documentText) {
|
||||
String documentText,
|
||||
int maxTitleLength) {
|
||||
|
||||
Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null");
|
||||
Objects.requireNonNull(promptContent, "promptContent must not be null");
|
||||
Objects.requireNonNull(documentText, "documentText must not be null");
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, but was: " + maxTitleLength);
|
||||
}
|
||||
|
||||
StringBuilder requestBuilder = new StringBuilder();
|
||||
requestBuilder.append(promptContent);
|
||||
@@ -128,7 +136,7 @@ public class AiRequestComposer {
|
||||
requestBuilder.append("\n");
|
||||
requestBuilder.append(documentText);
|
||||
requestBuilder.append("\n");
|
||||
appendJsonResponseFormat(requestBuilder);
|
||||
appendJsonResponseFormat(requestBuilder, maxTitleLength);
|
||||
|
||||
return requestBuilder.toString();
|
||||
}
|
||||
@@ -142,14 +150,17 @@ public class AiRequestComposer {
|
||||
* This specification is part of the deterministic composition and is included in
|
||||
* the actual request text sent to the AI service.
|
||||
*
|
||||
* @param requestBuilder the StringBuilder to append the format specification to
|
||||
* @param requestBuilder the StringBuilder to append the format specification to
|
||||
* @param maxTitleLength the configured maximum base title length; must be >= 1
|
||||
*/
|
||||
private static void appendJsonResponseFormat(StringBuilder requestBuilder) {
|
||||
private static void appendJsonResponseFormat(StringBuilder requestBuilder, int maxTitleLength) {
|
||||
requestBuilder.append("--- Response Format (JSON-only) ---");
|
||||
requestBuilder.append("\n");
|
||||
requestBuilder.append("Respond with a JSON object containing exactly:");
|
||||
requestBuilder.append("\n");
|
||||
requestBuilder.append(" \"title\": string (mandatory, max 20 characters, base title only)");
|
||||
requestBuilder.append(" \"title\": string (mandatory, max ")
|
||||
.append(maxTitleLength)
|
||||
.append(" characters, base title only)");
|
||||
requestBuilder.append("\n");
|
||||
requestBuilder.append(" \"reasoning\": string (mandatory, explanation of the decision)");
|
||||
requestBuilder.append("\n");
|
||||
|
||||
+16
-7
@@ -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 >= 1
|
||||
* @throws NullPointerException if {@code clockPort} is null
|
||||
* @throws IllegalArgumentException if {@code maxTitleLength} is less than 1
|
||||
*/
|
||||
public AiResponseValidator(ClockPort clockPort) {
|
||||
public AiResponseValidator(ClockPort clockPort, int maxTitleLength) {
|
||||
this.clockPort = Objects.requireNonNull(clockPort, "clockPort must not be null");
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, but was: " + maxTitleLength);
|
||||
}
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,9 +94,9 @@ public final class AiResponseValidator {
|
||||
// --- Title validation ---
|
||||
String title = parsed.title().trim();
|
||||
|
||||
if (title.length() > 60) {
|
||||
if (title.length() > maxTitleLength) {
|
||||
return AiValidationResult.invalid(
|
||||
"Title exceeds 60 characters (base title): '" + title + "'",
|
||||
"Title exceeds " + maxTitleLength + " characters (base title): '" + title + "'",
|
||||
AiErrorClassification.FUNCTIONAL);
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -155,17 +155,24 @@ public class DocumentProcessingCoordinator {
|
||||
private final TargetFileCopyPort targetFileCopyPort;
|
||||
private final ProcessingLogger logger;
|
||||
private final int maxRetriesTransient;
|
||||
private final int maxTitleLength;
|
||||
private final String activeProviderIdentifier;
|
||||
|
||||
/**
|
||||
* Creates the document processing coordinator with all required ports, logger,
|
||||
* the transient retry limit, and the active AI provider identifier.
|
||||
* the transient retry limit, the configured maximum base title length, and the
|
||||
* active AI provider identifier.
|
||||
* <p>
|
||||
* {@code maxRetriesTransient} is the maximum number of historised transient error attempts
|
||||
* per fingerprint before the document is finalised to
|
||||
* {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to
|
||||
* reach this value finalises the document. Must be >= 1.
|
||||
* <p>
|
||||
* {@code maxTitleLength} is the configured maximum length for the base title portion
|
||||
* of the generated target filename. The value is forwarded to
|
||||
* {@link TargetFilenameBuildingService} when the target filename is built from a
|
||||
* persisted naming proposal. Must be >= 1.
|
||||
* <p>
|
||||
* {@code activeProviderIdentifier} is the opaque string identifier of the AI provider
|
||||
* that is active for this run (e.g. {@code "openai-compatible"} or {@code "claude"}).
|
||||
* It is written to the attempt history for every attempt that involves an AI call,
|
||||
@@ -184,11 +191,13 @@ public class DocumentProcessingCoordinator {
|
||||
* @param logger for processing-related logging; must not be null
|
||||
* @param maxRetriesTransient maximum number of historised transient error attempts
|
||||
* before finalisation; must be >= 1
|
||||
* @param maxTitleLength configured maximum base title length; must be >= 1
|
||||
* @param activeProviderIdentifier opaque identifier of the active AI provider for this run;
|
||||
* must not be null or blank
|
||||
* @throws NullPointerException if any object parameter is null
|
||||
* @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1, or
|
||||
* if {@code activeProviderIdentifier} is blank
|
||||
* @throws IllegalArgumentException if {@code maxRetriesTransient} or {@code maxTitleLength}
|
||||
* is less than 1, or if {@code activeProviderIdentifier}
|
||||
* is blank
|
||||
*/
|
||||
public DocumentProcessingCoordinator(
|
||||
DocumentRecordRepository documentRecordRepository,
|
||||
@@ -198,11 +207,16 @@ public class DocumentProcessingCoordinator {
|
||||
TargetFileCopyPort targetFileCopyPort,
|
||||
ProcessingLogger logger,
|
||||
int maxRetriesTransient,
|
||||
int maxTitleLength,
|
||||
String activeProviderIdentifier) {
|
||||
if (maxRetriesTransient < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxRetriesTransient must be >= 1, got: " + maxRetriesTransient);
|
||||
}
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, got: " + maxTitleLength);
|
||||
}
|
||||
Objects.requireNonNull(activeProviderIdentifier, "activeProviderIdentifier must not be null");
|
||||
if (activeProviderIdentifier.isBlank()) {
|
||||
throw new IllegalArgumentException("activeProviderIdentifier must not be blank");
|
||||
@@ -219,6 +233,7 @@ public class DocumentProcessingCoordinator {
|
||||
Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
this.maxRetriesTransient = maxRetriesTransient;
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
this.activeProviderIdentifier = activeProviderIdentifier;
|
||||
}
|
||||
|
||||
@@ -425,7 +440,7 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
// --- Step 2: Build base filename from the proposal ---
|
||||
TargetFilenameBuildingService.BaseFilenameResult filenameResult =
|
||||
TargetFilenameBuildingService.buildBaseFilename(proposalAttempt);
|
||||
TargetFilenameBuildingService.buildBaseFilename(proposalAttempt, maxTitleLength);
|
||||
|
||||
if (filenameResult instanceof TargetFilenameBuildingService.InconsistentProposalState inconsistent) {
|
||||
logger.error("Inconsistent proposal state for '{}': {}",
|
||||
|
||||
+16
-9
@@ -45,7 +45,7 @@ public final class TargetFilenameBuildingService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sealed result of {@link #buildBaseFilename(ProcessingAttempt)}.
|
||||
* Sealed result of {@link #buildBaseFilename(ProcessingAttempt, int)}.
|
||||
*/
|
||||
public sealed interface BaseFilenameResult
|
||||
permits BaseFilenameReady, InconsistentProposalState {
|
||||
@@ -91,7 +91,8 @@ public final class TargetFilenameBuildingService {
|
||||
* <ul>
|
||||
* <li>Resolved date must be non-null.</li>
|
||||
* <li>Validated title must be non-null and non-blank.</li>
|
||||
* <li>Validated title must not exceed 60 characters (before Windows cleaning).</li>
|
||||
* <li>Validated title must not exceed the configured maximum length
|
||||
* (before Windows cleaning).</li>
|
||||
* <li>After Windows-character cleaning, title must contain only letters, digits, and spaces.</li>
|
||||
* </ul>
|
||||
* If any rule is violated, the state is treated as an
|
||||
@@ -100,18 +101,24 @@ public final class TargetFilenameBuildingService {
|
||||
* Windows compatibility: Windows-incompatible characters
|
||||
* (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation.
|
||||
* This ensures the resulting filename can be created on Windows systems.
|
||||
* The 60-character rule is applied to the original title before cleaning.
|
||||
* The maximum-length rule is applied to the original title before cleaning.
|
||||
* <p>
|
||||
* The 60-character limit applies exclusively to the base title. A duplicate-avoidance
|
||||
* The configured maximum length applies exclusively to the base title. A duplicate-avoidance
|
||||
* suffix (e.g., {@code (1)}) may be appended by the target folder adapter after this
|
||||
* method returns and is not counted against the 60 characters.
|
||||
* method returns and is not counted against the maximum.
|
||||
*
|
||||
* @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null
|
||||
* @param maxTitleLength the configured maximum base title length; must be >= 1
|
||||
* @return a {@link BaseFilenameReady} with the complete filename, or an
|
||||
* {@link InconsistentProposalState} describing the consistency violation
|
||||
* @throws IllegalArgumentException if {@code maxTitleLength} is less than 1
|
||||
*/
|
||||
public static BaseFilenameResult buildBaseFilename(ProcessingAttempt proposalAttempt) {
|
||||
public static BaseFilenameResult buildBaseFilename(ProcessingAttempt proposalAttempt, int maxTitleLength) {
|
||||
Objects.requireNonNull(proposalAttempt, "proposalAttempt must not be null");
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, but was: " + maxTitleLength);
|
||||
}
|
||||
|
||||
LocalDate date = proposalAttempt.resolvedDate();
|
||||
String title = proposalAttempt.validatedTitle();
|
||||
@@ -126,10 +133,10 @@ public final class TargetFilenameBuildingService {
|
||||
"Leading PROPOSAL_READY attempt has no validated title");
|
||||
}
|
||||
|
||||
if (title.length() > 60) {
|
||||
if (title.length() > maxTitleLength) {
|
||||
return new InconsistentProposalState(
|
||||
"Leading PROPOSAL_READY attempt has title exceeding 60 characters: '"
|
||||
+ title + "'");
|
||||
"Leading PROPOSAL_READY attempt has title exceeding " + maxTitleLength
|
||||
+ " characters: '" + title + "'");
|
||||
}
|
||||
|
||||
// Remove Windows-incompatible characters to enable technical Windows compatibility
|
||||
|
||||
+69
@@ -49,6 +49,7 @@ public class EditorConfigurationValidator {
|
||||
static final String FIELD_MAX_RETRIES = "max.retries.transient";
|
||||
static final String FIELD_MAX_PAGES = "max.pages";
|
||||
static final String FIELD_MAX_CHARS = "max.text.characters";
|
||||
static final String FIELD_MAX_TITLE_LENGTH = "max.title.length";
|
||||
|
||||
static final String FIELD_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
||||
static final String FIELD_CLAUDE_MODEL = "ai.provider.claude.model";
|
||||
@@ -64,6 +65,12 @@ public class EditorConfigurationValidator {
|
||||
private static final int MAX_CHARS_STRONG_WARNING_THRESHOLD = 3_000;
|
||||
private static final int MAX_PAGES_HINT_THRESHOLD = 100;
|
||||
|
||||
// Grenzen für die maximale Basistitel-Länge.
|
||||
private static final int TITLE_LENGTH_MIN = 10;
|
||||
private static final int TITLE_LENGTH_MAX = 120;
|
||||
private static final int TITLE_LENGTH_LOW_WARN_THRESHOLD = 20;
|
||||
private static final int TITLE_LENGTH_HIGH_WARN_THRESHOLD = 100;
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Instanz des Validators.
|
||||
* <p>
|
||||
@@ -149,6 +156,7 @@ public class EditorConfigurationValidator {
|
||||
validateMaxRetriesTransient(input.maxRetriesTransient(), findings);
|
||||
validateMaxPages(input.maxPages(), findings);
|
||||
validateMaxTextCharacters(input.maxTextCharacters(), findings);
|
||||
validateMaxTitleLength(input.maxTitleLength(), findings);
|
||||
}
|
||||
|
||||
private void validateMaxRetriesTransient(String rawValue, List<EditorValidationFinding> findings) {
|
||||
@@ -219,6 +227,67 @@ public class EditorConfigurationValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert den Rohwert für die maximale Basistitel-Länge.
|
||||
* <p>
|
||||
* Regeln:
|
||||
* <ul>
|
||||
* <li>leer: Fehler</li>
|
||||
* <li>nicht als ganze Zahl parsebar: Fehler</li>
|
||||
* <li>< {@value #TITLE_LENGTH_MIN}: Fehler (Minimum)</li>
|
||||
* <li>> {@value #TITLE_LENGTH_MAX}: Fehler (sicheres Maximum für verschlüsselte Volumes)</li>
|
||||
* <li>{@value #TITLE_LENGTH_MIN}–{@value #TITLE_LENGTH_LOW_WARN_THRESHOLD} (einschließlich
|
||||
* 19): Warnung (unter 20 Zeichen selten empfehlenswert)</li>
|
||||
* <li>≥ {@value #TITLE_LENGTH_HIGH_WARN_THRESHOLD}: Warnung (Kompatibilitätsrisiko
|
||||
* mit verschlüsselten Volumes)</li>
|
||||
* <li>andernfalls (20–99): kein Befund</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param rawValue Rohwert aus dem Editor; nie {@code null}
|
||||
* @param findings Zielliste für neue Befunde
|
||||
*/
|
||||
private void validateMaxTitleLength(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH,
|
||||
"Maximale Titellänge darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
int value;
|
||||
try {
|
||||
value = Integer.parseInt(rawValue.strip());
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH,
|
||||
"Maximale Titellänge muss eine ganze Zahl sein."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value < TITLE_LENGTH_MIN) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH,
|
||||
"Maximale Titellänge muss mindestens " + TITLE_LENGTH_MIN
|
||||
+ " sein (aktuell: " + value + ")."));
|
||||
return;
|
||||
}
|
||||
if (value > TITLE_LENGTH_MAX) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_TITLE_LENGTH,
|
||||
"Maximale Titellänge überschreitet sicheres Limit von " + TITLE_LENGTH_MAX
|
||||
+ " Zeichen (aktuell: " + value + ")."));
|
||||
return;
|
||||
}
|
||||
if (value < TITLE_LENGTH_LOW_WARN_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_MAX_TITLE_LENGTH,
|
||||
"Titellänge unter " + TITLE_LENGTH_LOW_WARN_THRESHOLD
|
||||
+ " Zeichen ist für die meisten Dokumente nicht empfohlen (aktuell: "
|
||||
+ value + ")."));
|
||||
return;
|
||||
}
|
||||
if (value >= TITLE_LENGTH_HIGH_WARN_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_MAX_TITLE_LENGTH,
|
||||
"Hohe Titellänge: Kompatibilität mit verschlüsselten Volumes prüfen "
|
||||
+ "(aktuell: " + value + ")."));
|
||||
}
|
||||
// 20–99: unkritisch, kein Befund
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aktiver Provider – providerabhängige Felder
|
||||
// =========================================================================
|
||||
|
||||
+4
@@ -24,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApi
|
||||
* @param maxRetriesTransient Rohtextwert von {@code max.retries.transient}
|
||||
* @param maxPages Rohtextwert von {@code max.pages}
|
||||
* @param maxTextCharacters Rohtextwert von {@code max.text.characters}
|
||||
* @param maxTitleLength Rohtextwert von {@code max.title.length}
|
||||
* @param claudeBaseUrl Rohtextwert der Claude-Basis-URL
|
||||
* @param claudeModel Rohtextwert des Claude-Modellnamens
|
||||
* @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts
|
||||
@@ -44,6 +45,7 @@ public record EditorValidationInput(
|
||||
String maxRetriesTransient,
|
||||
String maxPages,
|
||||
String maxTextCharacters,
|
||||
String maxTitleLength,
|
||||
String claudeBaseUrl,
|
||||
String claudeModel,
|
||||
String claudeTimeoutSeconds,
|
||||
@@ -66,6 +68,7 @@ public record EditorValidationInput(
|
||||
* @param maxRetriesTransient max. transiente Retries; {@code null} wird zu leerem String
|
||||
* @param maxPages max. Seitenzahl; {@code null} wird zu leerem String
|
||||
* @param maxTextCharacters max. Zeichenzahl; {@code null} wird zu leerem String
|
||||
* @param maxTitleLength max. Titellänge; {@code null} wird zu leerem String
|
||||
* @param claudeBaseUrl Claude-Basis-URL; {@code null} wird zu leerem String
|
||||
* @param claudeModel Claude-Modellname; {@code null} wird zu leerem String
|
||||
* @param claudeTimeoutSeconds Claude-Timeout; {@code null} wird zu leerem String
|
||||
@@ -87,6 +90,7 @@ public record EditorValidationInput(
|
||||
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
||||
maxPages = normalizeText(maxPages);
|
||||
maxTextCharacters = normalizeText(maxTextCharacters);
|
||||
maxTitleLength = normalizeText(maxTitleLength);
|
||||
claudeBaseUrl = normalizeText(claudeBaseUrl);
|
||||
claudeModel = normalizeText(claudeModel);
|
||||
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
|
||||
|
||||
+16
-3
@@ -67,21 +67,30 @@ public sealed interface CorrectionSuggestion
|
||||
* Die Erzeugung erfolgt nur, wenn der Zielpfad beschreibbar ist. Der konkrete
|
||||
* Standardinhalt wird vom {@link ResourceCreationPort} bereitgestellt. Der
|
||||
* Standardpfad liegt im selben Ordner wie die {@code .properties}-Datei.
|
||||
* <p>
|
||||
* Der {@code maxTitleLength} wird vom Adapter als Platzhalterwert in den erzeugten
|
||||
* Standardinhalt eingesetzt, damit der erzeugte Standard-Prompt inhaltlich zu der
|
||||
* konfigurierten maximalen Titellänge passt.
|
||||
*
|
||||
* @param path Pfad der anzulegenden Prompt-Datei als String; nie {@code null}
|
||||
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||
* @param maxTitleLength konfigurierte maximale Titellänge, die im erzeugten
|
||||
* Standardinhalt verwendet wird; muss {@code >= 1} sein
|
||||
*/
|
||||
record CreatePromptFile(
|
||||
String path,
|
||||
String descriptionForUser) implements CorrectionSuggestion {
|
||||
String descriptionForUser,
|
||||
int maxTitleLength) implements CorrectionSuggestion {
|
||||
|
||||
/**
|
||||
* Erstellt einen Vorschlag zum Erzeugen einer deutschen Standard-Prompt-Datei.
|
||||
*
|
||||
* @param path Pfad der Prompt-Datei; darf nicht {@code null} oder leer sein
|
||||
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||
* @param maxTitleLength konfigurierte maximale Titellänge; muss {@code >= 1} sein
|
||||
* @throws NullPointerException wenn {@code path} oder {@code descriptionForUser} {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code path} leer ist oder
|
||||
* {@code maxTitleLength < 1}
|
||||
*/
|
||||
public CreatePromptFile {
|
||||
Objects.requireNonNull(path, "path must not be null");
|
||||
@@ -89,6 +98,10 @@ public sealed interface CorrectionSuggestion
|
||||
if (path.isBlank()) {
|
||||
throw new IllegalArgumentException("path must not be blank");
|
||||
}
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, got: " + maxTitleLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-5
@@ -17,6 +17,13 @@ package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
*/
|
||||
public final class DefaultPromptTemplate {
|
||||
|
||||
/**
|
||||
* Platzhalter für die konfigurierte maximale Titellänge. Wird vor der Rückgabe durch den
|
||||
* konkreten numerischen Wert ersetzt, damit die erzeugte Prompt-Datei inhaltlich zu der
|
||||
* konfigurierten maximalen Titellänge passt.
|
||||
*/
|
||||
private static final String MAX_TITLE_LENGTH_PLACEHOLDER = "{MAX_TITLE_LENGTH}";
|
||||
|
||||
private DefaultPromptTemplate() {
|
||||
// Utility-Klasse – keine Instanziierung
|
||||
}
|
||||
@@ -28,18 +35,28 @@ public final class DefaultPromptTemplate {
|
||||
* <ul>
|
||||
* <li>Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)</li>
|
||||
* <li>Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}</li>
|
||||
* <li>Benennungsregeln für Titel (maximal 20 Zeichen, deutsch, keine Sonderzeichen)</li>
|
||||
* <li>Benennungsregeln für Titel (maximal {@code maxTitleLength} Zeichen, deutsch, keine Sonderzeichen)</li>
|
||||
* <li>Hinweis auf das Datumsformat ({@code YYYY-MM-DD})</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Der Text enthält keinen Platzhalter für den Dokumentinhalt. Der Dokumenttext
|
||||
* wird vom {@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer}
|
||||
* separat angehängt.
|
||||
* separat angehängt. Der Standardinhalt enthält zusätzlich den Platzhalter
|
||||
* {@value #MAX_TITLE_LENGTH_PLACEHOLDER}, damit die zur Laufzeit bestimmte Titellänge
|
||||
* auch beim erneuten Laden der Datei durch den {@code AiNamingService} korrekt ersetzt
|
||||
* werden kann.
|
||||
*
|
||||
* @param maxTitleLength die konfigurierte maximale Titellänge, die in den erzeugten
|
||||
* Standardtext eingesetzt wird; muss {@code >= 1} sein
|
||||
* @return der deutsche Standard-Prompt-Inhalt; nie {@code null}, nie leer
|
||||
* @throws IllegalArgumentException wenn {@code maxTitleLength < 1}
|
||||
*/
|
||||
public static String defaultContent() {
|
||||
return """
|
||||
public static String defaultContent(int maxTitleLength) {
|
||||
if (maxTitleLength < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxTitleLength must be >= 1, got: " + maxTitleLength);
|
||||
}
|
||||
String template = """
|
||||
Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
|
||||
Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
|
||||
einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
|
||||
@@ -57,12 +74,13 @@ public final class DefaultPromptTemplate {
|
||||
- Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
|
||||
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
|
||||
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
|
||||
- Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
|
||||
- Der Titel hat maximal {MAX_TITLE_LENGTH} Zeichen (Basistitel ohne Suffix).
|
||||
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
|
||||
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||
- Eigennamen bleiben unverändert.
|
||||
- Umlaute und ß sind erlaubt.
|
||||
- Kein Text außerhalb des JSON-Objekts.
|
||||
""";
|
||||
return template.replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength));
|
||||
}
|
||||
}
|
||||
|
||||
+45
-5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user