M11 vollständig abgeschlossen (AP-001 bis AP-007)
- AP-001: Kernobjekte und Port-Verträge (ModelCatalog-Port, sealed Result-Typen, ApiKeyOrigin, GUI-Modell- und Meldungs-Records) - AP-002: Provider-ComboBox, exklusiver Providerbereich, zustandsbewahrender Providerwechsel - AP-003: HTTP-Adapter für Modellabruf (Claude, OpenAI-kompatibel) mit vollständigem Error-Mapping und Dispatcher im Bootstrap - AP-004: Automatischer Modellabruf bei Providerwechsel, Aktion "Modelle neu laden", Umschaltung zwischen Modell-ComboBox und Modell-Textfeld, Worker-Thread-Kapselung - AP-005: Automatische Editorvalidierung (Pflichtfelder, Warnschwellen max.text.characters, Plausibilitätshinweise max.pages, API-Key-Herkunftsauflösung mit Vorrangregel) - AP-006: Zentraler Meldungsbereich mit vier Severity-Stufen, feldnahe rote Fehlermeldungen, API-Key-Herkunftsanzeige - AP-007: Integrations- und Regressionstests, Timeout-Mapping-Tests, Replace-Semantik für wiederholte Modellabruf-Meldungen Hexagonale Architektur eingehalten, Application- und Domain-Schicht bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt. Naming-Regel und JavaDoc-Standard durchgängig beachtet. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
/**
|
||||
* Outbound port for retrieving the list of available AI models from a provider endpoint.
|
||||
* <p>
|
||||
* This port is used exclusively by the GUI layer to populate the model selection control.
|
||||
* The headless batch path does not call this port; the model name is read directly from
|
||||
* the configuration file in that path.
|
||||
* <p>
|
||||
* <strong>Infrastructure neutrality:</strong> The port signature contains no HTTP client,
|
||||
* no JSON type, and no JavaFX reference. Implementations in {@code pdf-umbenenner-adapter-out}
|
||||
* translate the {@link ModelCatalogRequest} into provider-specific HTTP calls and map the
|
||||
* response back to a {@link ModelCatalogResult}.
|
||||
* <p>
|
||||
* <strong>Error handling contract:</strong> Implementations must never throw exceptions for
|
||||
* expected error conditions such as missing configuration values, authentication failures, or
|
||||
* unreachable endpoints. All such conditions are encoded as specific {@link ModelCatalogResult}
|
||||
* sub-types so the GUI can display them without crash handling.
|
||||
* <p>
|
||||
* <strong>Blocking behaviour:</strong> Implementations are expected to block the calling thread
|
||||
* until the result is available or the configured timeout expires. The GUI adapter must therefore
|
||||
* invoke this port on a background worker thread and must not call it on the JavaFX Application
|
||||
* Thread.
|
||||
*/
|
||||
public interface AiModelCatalogPort {
|
||||
|
||||
/**
|
||||
* Fetches the list of available model identifiers for the provider described by the request.
|
||||
* <p>
|
||||
* The method always returns a non-{@code null} {@link ModelCatalogResult}. The caller
|
||||
* uses pattern matching over the sealed type hierarchy to distinguish success, empty list,
|
||||
* incomplete configuration, and technical failure without catching exceptions.
|
||||
*
|
||||
* @param request all information required to contact the provider endpoint;
|
||||
* must not be {@code null}
|
||||
* @return the result of the catalogue retrieval; never {@code null}
|
||||
* @throws NullPointerException if {@code request} is {@code null}
|
||||
*/
|
||||
ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
/**
|
||||
* Describes the origin of the effective API key value for a provider.
|
||||
* <p>
|
||||
* The resolution order is defined in the application configuration rules:
|
||||
* <ol>
|
||||
* <li>A provider-specific environment variable takes the highest precedence.</li>
|
||||
* <li>For the {@code openai-compatible} provider family a legacy environment variable
|
||||
* is evaluated as a secondary fallback for backward compatibility.</li>
|
||||
* <li>The property value from the {@code .properties} file is used when no
|
||||
* environment variable is present.</li>
|
||||
* <li>{@link #ABSENT} is returned when none of the above sources supplies a value.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* This enum is placed in the Application module because the resolution order is a fachliche
|
||||
* rule that applies independently of the GUI. Bootstrap uses the same precedence, and
|
||||
* future diagnostic components outside the GUI may need to report key provenance without
|
||||
* depending on GUI-layer types.
|
||||
*/
|
||||
public enum ApiKeyOrigin {
|
||||
|
||||
/**
|
||||
* The effective key comes from the provider-specific environment variable
|
||||
* (e.g., {@code ANTHROPIC_API_KEY} for Claude or {@code OPENAI_API_KEY} for
|
||||
* OpenAI-compatible providers).
|
||||
*/
|
||||
FROM_PROVIDER_ENV_VAR,
|
||||
|
||||
/**
|
||||
* The effective key comes from the legacy environment variable accepted for
|
||||
* backward compatibility.
|
||||
* <p>
|
||||
* This origin applies only to the {@code openai-compatible} provider family. The
|
||||
* legacy variable name is defined by the adapter and is not fixed in this enum.
|
||||
*/
|
||||
FROM_LEGACY_ENV_VAR,
|
||||
|
||||
/**
|
||||
* The effective key comes from the property value stored in the {@code .properties} file.
|
||||
*/
|
||||
FROM_PROPERTY_FILE,
|
||||
|
||||
/**
|
||||
* No API key value was found in any of the supported sources.
|
||||
* <p>
|
||||
* The provider configuration is incomplete; the batch run cannot start and the GUI
|
||||
* must indicate that the key is missing.
|
||||
*/
|
||||
ABSENT
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Describes the provenance of the effective API key for a provider, including the name of the
|
||||
* environment variable that supplies the key when applicable.
|
||||
* <p>
|
||||
* The GUI uses this record to display provenance information to the user (e.g., "Schlüssel
|
||||
* kommt aus Umgebungsvariable ANTHROPIC_API_KEY") without coupling the display logic to the
|
||||
* concrete variable names.
|
||||
* <p>
|
||||
* The {@code envVarName} field is present when the origin is
|
||||
* {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} or {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR};
|
||||
* it is absent for {@link ApiKeyOrigin#FROM_PROPERTY_FILE} and {@link ApiKeyOrigin#ABSENT}.
|
||||
*
|
||||
* @param origin the source from which the effective key value comes; never {@code null}
|
||||
* @param envVarName the name of the environment variable that provides the key when applicable;
|
||||
* empty when the origin is {@code FROM_PROPERTY_FILE} or {@code ABSENT}
|
||||
*/
|
||||
public record EffectiveApiKeyDescriptor(
|
||||
ApiKeyOrigin origin,
|
||||
Optional<String> envVarName) {
|
||||
|
||||
/**
|
||||
* Creates a new descriptor.
|
||||
*
|
||||
* @param origin key origin; must not be {@code null}
|
||||
* @param envVarName optional environment variable name; {@code null} becomes empty
|
||||
* @throws NullPointerException if {@code origin} is {@code null}
|
||||
*/
|
||||
public EffectiveApiKeyDescriptor {
|
||||
Objects.requireNonNull(origin, "origin must not be null");
|
||||
envVarName = envVarName == null ? Optional.empty() : envVarName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor for a key that comes from a provider-specific environment variable.
|
||||
*
|
||||
* @param variableName the name of the environment variable; must not be {@code null}
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR}
|
||||
* @throws NullPointerException if {@code variableName} is {@code null}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor fromProviderEnvVar(String variableName) {
|
||||
Objects.requireNonNull(variableName, "variableName must not be null");
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
|
||||
Optional.of(variableName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor for a key that comes from the legacy environment variable.
|
||||
*
|
||||
* @param variableName the name of the legacy environment variable; must not be {@code null}
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
|
||||
* @throws NullPointerException if {@code variableName} is {@code null}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor fromLegacyEnvVar(String variableName) {
|
||||
Objects.requireNonNull(variableName, "variableName must not be null");
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
|
||||
Optional.of(variableName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor for a key that comes from the properties file.
|
||||
*
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROPERTY_FILE}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor fromPropertyFile() {
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a descriptor indicating that no key value is available.
|
||||
*
|
||||
* @return a new descriptor with origin {@link ApiKeyOrigin#ABSENT}
|
||||
*/
|
||||
public static EffectiveApiKeyDescriptor absent() {
|
||||
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.ABSENT, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when the effective key comes from any environment variable.
|
||||
*
|
||||
* @return {@code true} for {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} and
|
||||
* {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
|
||||
*/
|
||||
public boolean isFromEnvironmentVariable() {
|
||||
return origin == ApiKeyOrigin.FROM_PROVIDER_ENV_VAR
|
||||
|| origin == ApiKeyOrigin.FROM_LEGACY_ENV_VAR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when no key is available from any source.
|
||||
*
|
||||
* @return {@code true} for {@link ApiKeyOrigin#ABSENT}
|
||||
*/
|
||||
public boolean isAbsent() {
|
||||
return origin == ApiKeyOrigin.ABSENT;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Carries all information needed by an {@link AiModelCatalogPort} implementation to fetch
|
||||
* the list of available models from a provider endpoint.
|
||||
* <p>
|
||||
* This record is infrastructure-neutral: it contains no HTTP client, no JSON type, and no
|
||||
* JavaFX reference. The adapter translates the values into provider-specific request structures.
|
||||
* <p>
|
||||
* The {@code apiKey} field is {@code Optional}: when the key is absent the adapter is expected
|
||||
* to perform the request without authentication and return an appropriate
|
||||
* {@link ModelCatalogResult} sub-type (typically
|
||||
* {@link ModelCatalogResult.IncompleteConfiguration}) rather than throwing an exception.
|
||||
*
|
||||
* @param providerIdentifier identifier string of the target provider family as used in the
|
||||
* {@code ai.provider.active} configuration property
|
||||
* (e.g., {@code "claude"} or {@code "openai-compatible"});
|
||||
* must not be {@code null}
|
||||
* @param baseUrl optional base URL of the provider endpoint; when absent the adapter
|
||||
* must apply its own built-in default (e.g., for Claude)
|
||||
* @param apiKey optional API key; when absent the adapter must not fabricate a key
|
||||
* @param timeoutSeconds HTTP timeout in seconds; must be a positive integer
|
||||
*/
|
||||
public record ModelCatalogRequest(
|
||||
String providerIdentifier,
|
||||
Optional<String> baseUrl,
|
||||
Optional<String> apiKey,
|
||||
int timeoutSeconds) {
|
||||
|
||||
/**
|
||||
* Creates a new model catalogue request.
|
||||
*
|
||||
* @param providerIdentifier identifier string of the target provider family; must not be {@code null}
|
||||
* @param baseUrl optional base URL; {@code null} is treated as {@link Optional#empty()}
|
||||
* @param apiKey optional API key; {@code null} is treated as {@link Optional#empty()}
|
||||
* @param timeoutSeconds HTTP timeout in seconds; must be positive
|
||||
* @throws NullPointerException if {@code providerIdentifier} is {@code null}
|
||||
* @throws IllegalArgumentException if {@code timeoutSeconds} is not positive
|
||||
*/
|
||||
public ModelCatalogRequest {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
if (timeoutSeconds <= 0) {
|
||||
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
|
||||
}
|
||||
baseUrl = baseUrl == null ? Optional.empty() : baseUrl;
|
||||
apiKey = apiKey == null ? Optional.empty() : apiKey;
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Sealed result type for a model catalogue retrieval operation performed via
|
||||
* {@link AiModelCatalogPort}.
|
||||
* <p>
|
||||
* Each permitted sub-type represents one distinct outcome:
|
||||
* <ul>
|
||||
* <li>{@link Success} – the provider returned a non-empty list of model identifiers.</li>
|
||||
* <li>{@link EmptyList} – the provider responded successfully but returned no models.</li>
|
||||
* <li>{@link IncompleteConfiguration} – the request could not be sent because a required
|
||||
* configuration value (e.g., API key, base URL) was missing.</li>
|
||||
* <li>{@link TechnicalFailure} – the HTTP call, authentication, or response parsing failed.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The GUI adapter uses this result directly; no separate GUI-layer translation type is needed
|
||||
* because the structure matches the GUI's display needs without containing any JavaFX reference.
|
||||
* This design decision is documented here to avoid introducing a redundant mapping layer.
|
||||
* <p>
|
||||
* Callers are expected to use pattern-matching {@code switch} expressions over all permitted types
|
||||
* to ensure exhaustive handling as new sub-types might be added in future expansions.
|
||||
*/
|
||||
public sealed interface ModelCatalogResult
|
||||
permits ModelCatalogResult.Success,
|
||||
ModelCatalogResult.EmptyList,
|
||||
ModelCatalogResult.IncompleteConfiguration,
|
||||
ModelCatalogResult.TechnicalFailure {
|
||||
|
||||
/**
|
||||
* The provider returned a non-empty list of available model identifiers.
|
||||
* <p>
|
||||
* The list is guaranteed to contain at least one entry. Callers may safely use
|
||||
* the first element as a default selection.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider that returned the list; never {@code null}
|
||||
* @param models non-empty, ordered list of model identifier strings; never {@code null}
|
||||
* @param loadedAt timestamp when the list was successfully retrieved; never {@code null}
|
||||
*/
|
||||
record Success(
|
||||
String providerIdentifier,
|
||||
List<String> models,
|
||||
Instant loadedAt) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates a successful model catalogue result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param models list of model identifiers; must not be {@code null} or empty
|
||||
* @param loadedAt retrieval timestamp; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
* @throws IllegalArgumentException if {@code models} is empty
|
||||
*/
|
||||
public Success {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(models, "models must not be null");
|
||||
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||
if (models.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"models must not be empty; use EmptyList for an empty response");
|
||||
}
|
||||
models = List.copyOf(models);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider responded successfully but returned no model identifiers.
|
||||
* <p>
|
||||
* This case is distinct from {@link TechnicalFailure}: the HTTP exchange succeeded and the
|
||||
* response was parseable, but the list of models was empty. The GUI should fall back to
|
||||
* manual text input.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; never {@code null}
|
||||
* @param loadedAt timestamp of the (technically successful) response; never {@code null}
|
||||
*/
|
||||
record EmptyList(
|
||||
String providerIdentifier,
|
||||
Instant loadedAt) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates an empty-list result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param loadedAt retrieval timestamp; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public EmptyList {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model catalogue request could not be sent because a required configuration value
|
||||
* was absent.
|
||||
* <p>
|
||||
* Typical causes: missing API key, missing base URL for a provider family that requires one.
|
||||
* The adapter must not throw an exception for this case; it must return this result type
|
||||
* instead so the GUI can display a user-friendly hint without crashing.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider for which configuration is incomplete;
|
||||
* never {@code null}
|
||||
* @param missingReason human-readable description of which configuration value is missing;
|
||||
* never {@code null}
|
||||
*/
|
||||
record IncompleteConfiguration(
|
||||
String providerIdentifier,
|
||||
String missingReason) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates an incomplete-configuration result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param missingReason description of the missing value; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public IncompleteConfiguration {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(missingReason, "missingReason must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A technical error occurred during the model catalogue retrieval.
|
||||
* <p>
|
||||
* Covers HTTP errors, authentication failures, network timeouts, and response parsing
|
||||
* failures. The adapter classifies the error into a short category string (e.g.,
|
||||
* {@code "HTTP_ERROR"}, {@code "AUTH_FAILURE"}, {@code "TIMEOUT"}, {@code "PARSE_ERROR"})
|
||||
* and provides a human-readable detail message.
|
||||
* <p>
|
||||
* This result does not trigger a retry; the GUI offers an explicit
|
||||
* "reload models" action that the user can invoke after fixing the underlying issue.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider that was contacted; never {@code null}
|
||||
* @param errorCategory short, stable category string for programmatic discrimination;
|
||||
* never {@code null}
|
||||
* @param errorDetail human-readable error description; never {@code null}
|
||||
*/
|
||||
record TechnicalFailure(
|
||||
String providerIdentifier,
|
||||
String errorCategory,
|
||||
String errorDetail) implements ModelCatalogResult {
|
||||
|
||||
/**
|
||||
* Creates a technical-failure result.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param errorCategory short category string; must not be {@code null}
|
||||
* @param errorDetail human-readable detail; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public TechnicalFailure {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(errorCategory, "errorCategory must not be null");
|
||||
Objects.requireNonNull(errorDetail, "errorDetail must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Contracts and result types for the provider-dependent model catalogue retrieval.
|
||||
* <p>
|
||||
* This package defines the outbound port {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort}
|
||||
* that the GUI adapter uses to fetch the list of available AI models from the active provider.
|
||||
* All types in this package are infrastructure-neutral: they contain no HTTP, JSON, or JavaFX
|
||||
* references.
|
||||
* <p>
|
||||
* The GUI adapter is the sole consumer of this port in the current implementation scope;
|
||||
* the headless batch path does not perform model catalogue lookups.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Outbound-Port für die Auflösung der API-Key-Herkunft pro Provider-Familie.
|
||||
* <p>
|
||||
* Gibt zurück, aus welcher Quelle der effektive API-Schlüssel für einen angegebenen Provider
|
||||
* stammt. Die Vorrangregel lautet:
|
||||
* <ol>
|
||||
* <li>Providerspezifische Umgebungsvariable (höchste Priorität)</li>
|
||||
* <li>Bei {@code openai-compatible}: zusätzlich die Legacy-Umgebungsvariable als Fallback</li>
|
||||
* <li>Property-Wert aus der {@code .properties}-Datei</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT}
|
||||
* wenn keine Quelle einen Wert liefert</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Der Port kennt keine Datei-Inhalte und kein Property-Parsing; er liest ausschließlich
|
||||
* Umgebungsvariablen. Der Property-Wert wird von der GUI als separates Eingabeargument übergeben
|
||||
* und vom Validator mit dem Port-Ergebnis kombiniert.
|
||||
* <p>
|
||||
* Implementierungen dieses Ports liegen im Adapter-Out-Modul.
|
||||
*/
|
||||
public interface ApiKeyResolutionPort {
|
||||
|
||||
/**
|
||||
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
|
||||
* <p>
|
||||
* Gibt nur dann {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#FROM_PROPERTY_FILE}
|
||||
* zurück, wenn {@code propertyValue} nicht leer ist und keine Umgebungsvariable greift.
|
||||
* Gibt {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT} zurück,
|
||||
* wenn weder Umgebungsvariable noch {@code propertyValue} einen Wert liefern.
|
||||
*
|
||||
* @param family die Provider-Familie; darf nicht {@code null} sein
|
||||
* @param propertyValue aktueller Property-Wert aus dem Editor (kann leer sein); darf nicht {@code null} sein
|
||||
* @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null}
|
||||
*/
|
||||
EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue);
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Zentraler Validierungsbaustein für den aktuellen Editorzustand des Konfigurationseditors.
|
||||
* <p>
|
||||
* Dieser Validator arbeitet ausschließlich auf den übergebenen String-Werten des
|
||||
* {@link EditorValidationInput}, ohne Dateisystemzugriffe, Datenbankroundtrips oder
|
||||
* Netzwerkkommunikation. Er erzeugt Befunde der Stufen Fehler, Warnung, Hinweis und Info.
|
||||
* <p>
|
||||
* Folgende Prüfungen sind ausdrücklich ausgeschlossen (gehören in spätere technische Gesamtprüfungen):
|
||||
* <ul>
|
||||
* <li>Pfad-Existenzprüfungen (Quellordner, Zielordner, SQLite-Datei, Prompt-Datei)</li>
|
||||
* <li>SQLite-Roundtrips</li>
|
||||
* <li>Netzwerkverbindungen (Modellabruf, API-Erreichbarkeit)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* API-Key-Vorrangregel (fachlich verbindlich):
|
||||
* <ol>
|
||||
* <li>Providerspezifische Umgebungsvariable</li>
|
||||
* <li>Bei {@code openai-compatible}: Legacy-Umgebungsvariable als Fallback</li>
|
||||
* <li>Property-Wert aus der Datei</li>
|
||||
* <li>ABSENT, wenn keine Quelle einen Wert liefert</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Warnlogik für {@code max.text.characters}:
|
||||
* <ul>
|
||||
* <li>1–1.000: unkritisch (kein Befund)</li>
|
||||
* <li>1.001–3.000: Warnung</li>
|
||||
* <li>ab 3.001: starke Warnung</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class EditorConfigurationValidator {
|
||||
|
||||
// Property-Schlüssel als Konstanten (identisch mit den .properties-Schlüsseln)
|
||||
static final String FIELD_ACTIVE_PROVIDER = "ai.provider.active";
|
||||
static final String FIELD_SOURCE_FOLDER = "source.folder";
|
||||
static final String FIELD_TARGET_FOLDER = "target.folder";
|
||||
static final String FIELD_SQLITE_FILE = "sqlite.file";
|
||||
static final String FIELD_PROMPT_FILE = "prompt.template.file";
|
||||
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_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
||||
static final String FIELD_CLAUDE_MODEL = "ai.provider.claude.model";
|
||||
static final String FIELD_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
|
||||
static final String FIELD_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
|
||||
|
||||
static final String FIELD_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
|
||||
static final String FIELD_OPENAI_MODEL = "ai.provider.openai-compatible.model";
|
||||
static final String FIELD_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
|
||||
static final String FIELD_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
|
||||
|
||||
private static final int MAX_CHARS_WARNING_THRESHOLD = 1_000;
|
||||
private static final int MAX_CHARS_STRONG_WARNING_THRESHOLD = 3_000;
|
||||
private static final int MAX_PAGES_HINT_THRESHOLD = 100;
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Instanz des Validators.
|
||||
* <p>
|
||||
* Dieser Validator benötigt keine Abhängigkeiten; alle Prüfungen sind rein in-memory.
|
||||
*/
|
||||
public EditorConfigurationValidator() {
|
||||
// Kein State nötig; alle Prüfungen arbeiten auf dem übergebenen Input.
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert den aktuellen Editorzustand und liefert einen Befund-Bericht.
|
||||
* <p>
|
||||
* Die Methode ist schnell (keine I/O) und darf auf dem JavaFX Application Thread
|
||||
* aufgerufen werden. Der zurückgegebene Bericht ist immutable.
|
||||
*
|
||||
* @param input der aktuelle Editorzustand; darf nicht {@code null} sein
|
||||
* @return der Validierungsbericht mit allen gefundenen Befunden; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code input} {@code null} ist
|
||||
*/
|
||||
public EditorValidationReport validate(EditorValidationInput input) {
|
||||
Objects.requireNonNull(input, "input must not be null");
|
||||
|
||||
List<EditorValidationFinding> findings = new ArrayList<>();
|
||||
|
||||
validateActiveProvider(input, findings);
|
||||
validateRequiredPaths(input, findings);
|
||||
validateNumericLimits(input, findings);
|
||||
validateActiveProviderFields(input, findings);
|
||||
|
||||
return new EditorValidationReport(findings);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aktiver Provider
|
||||
// =========================================================================
|
||||
|
||||
private void validateActiveProvider(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
String identifier = input.activeProviderIdentifier();
|
||||
if (identifier.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
|
||||
"Es muss ein aktiver Provider ausgewählt sein."));
|
||||
return;
|
||||
}
|
||||
Optional<AiProviderFamily> resolved = AiProviderFamily.fromIdentifier(identifier);
|
||||
if (resolved.isEmpty()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
|
||||
"Der angegebene Provider '" + identifier + "' ist nicht bekannt. "
|
||||
+ "Erlaubt sind: 'claude' und 'openai-compatible'."));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pflichtpfade
|
||||
// =========================================================================
|
||||
|
||||
private void validateRequiredPaths(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
if (input.sourceFolder().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_SOURCE_FOLDER,
|
||||
"Quellordner darf nicht leer sein."));
|
||||
}
|
||||
if (input.targetFolder().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_TARGET_FOLDER,
|
||||
"Zielordner darf nicht leer sein."));
|
||||
}
|
||||
if (input.sqliteFile().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_SQLITE_FILE,
|
||||
"SQLite-Datei darf nicht leer sein."));
|
||||
}
|
||||
if (input.promptTemplateFile().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_PROMPT_FILE,
|
||||
"Prompt-Datei darf nicht leer sein."));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Numerische Limits
|
||||
// =========================================================================
|
||||
|
||||
private void validateNumericLimits(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
validateMaxRetriesTransient(input.maxRetriesTransient(), findings);
|
||||
validateMaxPages(input.maxPages(), findings);
|
||||
validateMaxTextCharacters(input.maxTextCharacters(), findings);
|
||||
}
|
||||
|
||||
private void validateMaxRetriesTransient(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||
"Maximale transiente Retries darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value < 1) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||
"Maximale transiente Retries muss mindestens 1 sein (aktuell: " + value + "). "
|
||||
+ "Der Wert 0 ist unzulässig."));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||
"Maximale transiente Retries muss eine ganze Zahl >= 1 sein."));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMaxPages(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||
"Maximale Seitenzahl darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value <= 0) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||
"Maximale Seitenzahl muss positiv sein (aktuell: " + value + ")."));
|
||||
} else if (value > MAX_PAGES_HINT_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.hint(FIELD_MAX_PAGES,
|
||||
"Plausibilitätshinweis: Über " + MAX_PAGES_HINT_THRESHOLD
|
||||
+ " Seiten je Datei könnten die Verarbeitung verlangsamen (aktuell: " + value + ")."));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||
"Maximale Seitenzahl muss eine positive ganze Zahl sein."));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMaxTextCharacters(String rawValue, List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||
"Maximale Zeichenzahl darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value <= 0) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||
"Maximale Zeichenzahl muss positiv sein (aktuell: " + value + ")."));
|
||||
} else if (value > MAX_CHARS_STRONG_WARNING_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
|
||||
"Stark erhöhte Zeichenmenge: " + value + " Zeichen können zu riskant hohen "
|
||||
+ "API-Kosten je Verarbeitungsaufruf führen."));
|
||||
} else if (value > MAX_CHARS_WARNING_THRESHOLD) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
|
||||
"Erhöhte Zeichenmenge: " + value + " Zeichen. Beachten Sie mögliche Auswirkungen "
|
||||
+ "auf die API-Kosten je Verarbeitungsaufruf."));
|
||||
}
|
||||
// 1–1000: unkritisch, kein Befund
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||
"Maximale Zeichenzahl muss eine positive ganze Zahl sein."));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aktiver Provider – providerabhängige Felder
|
||||
// =========================================================================
|
||||
|
||||
private void validateActiveProviderFields(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
Optional<AiProviderFamily> resolvedProvider =
|
||||
AiProviderFamily.fromIdentifier(input.activeProviderIdentifier());
|
||||
if (resolvedProvider.isEmpty()) {
|
||||
// Provider unbekannt – feldspezifische Provider-Prüfungen nicht möglich
|
||||
return;
|
||||
}
|
||||
|
||||
AiProviderFamily family = resolvedProvider.get();
|
||||
switch (family) {
|
||||
case CLAUDE -> validateClaudeFields(input, findings);
|
||||
case OPENAI_COMPATIBLE -> validateOpenAiFields(input, findings);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateClaudeFields(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
// Basis-URL: leer ist Warnung (Default-URL wird verwendet)
|
||||
if (input.claudeBaseUrl().isBlank()) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_CLAUDE_BASE_URL,
|
||||
"Basis-URL nicht gesetzt – es wird die Standard-URL verwendet."));
|
||||
}
|
||||
|
||||
// Modell: Pflichtfeld
|
||||
if (input.claudeModel().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_CLAUDE_MODEL,
|
||||
"Modellname darf nicht leer sein."));
|
||||
}
|
||||
|
||||
// Timeout
|
||||
validateTimeoutField(input.claudeTimeoutSeconds(), FIELD_CLAUDE_TIMEOUT, findings);
|
||||
|
||||
// API-Key
|
||||
validateApiKeyFindings(input.claudeApiKeyDescriptor(), FIELD_CLAUDE_API_KEY, findings);
|
||||
}
|
||||
|
||||
private void validateOpenAiFields(EditorValidationInput input,
|
||||
List<EditorValidationFinding> findings) {
|
||||
// Basis-URL: leer ist Warnung
|
||||
if (input.openaiBaseUrl().isBlank()) {
|
||||
findings.add(EditorValidationFinding.warning(FIELD_OPENAI_BASE_URL,
|
||||
"Basis-URL nicht gesetzt – es wird die Standard-URL verwendet."));
|
||||
}
|
||||
|
||||
// Modell: Pflichtfeld
|
||||
if (input.openaiModel().isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(FIELD_OPENAI_MODEL,
|
||||
"Modellname darf nicht leer sein."));
|
||||
}
|
||||
|
||||
// Timeout
|
||||
validateTimeoutField(input.openaiTimeoutSeconds(), FIELD_OPENAI_TIMEOUT, findings);
|
||||
|
||||
// API-Key
|
||||
validateApiKeyFindings(input.openaiApiKeyDescriptor(), FIELD_OPENAI_API_KEY, findings);
|
||||
}
|
||||
|
||||
private void validateTimeoutField(String rawValue, String fieldKey,
|
||||
List<EditorValidationFinding> findings) {
|
||||
if (rawValue.isBlank()) {
|
||||
findings.add(EditorValidationFinding.error(fieldKey,
|
||||
"Timeout-Wert darf nicht leer sein."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(rawValue.strip());
|
||||
if (value <= 0) {
|
||||
findings.add(EditorValidationFinding.error(fieldKey,
|
||||
"Timeout-Wert muss positiv sein (aktuell: " + value + ")."));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
findings.add(EditorValidationFinding.error(fieldKey,
|
||||
"Timeout-Wert muss eine positive ganze Zahl sein."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt API-Key-Befunde gemäß der Vorrangregel.
|
||||
* <p>
|
||||
* Vorrangregel:
|
||||
* <ol>
|
||||
* <li>ENV-Variable aktiv: INFO-Befund mit Variablenname</li>
|
||||
* <li>Property-Wert in Datei, keine ENV: normal (kein Befund)</li>
|
||||
* <li>Leeres Property-Feld und keine ENV: WARNING</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param descriptor Herkunft des effektiven API-Schlüssels
|
||||
* @param fieldKey Property-Schlüssel des API-Key-Felds
|
||||
* @param findings Zielliste für neue Befunde
|
||||
*/
|
||||
private void validateApiKeyFindings(EffectiveApiKeyDescriptor descriptor,
|
||||
String fieldKey,
|
||||
List<EditorValidationFinding> findings) {
|
||||
ApiKeyOrigin origin = descriptor.origin();
|
||||
|
||||
switch (origin) {
|
||||
case FROM_PROVIDER_ENV_VAR -> {
|
||||
String varName = descriptor.envVarName().orElse("unbekannte ENV-Variable");
|
||||
findings.add(EditorValidationFinding.info(fieldKey,
|
||||
"API-Schlüssel stammt aus Umgebungsvariable " + varName
|
||||
+ " (hat Vorrang vor dem Datei-Wert)."));
|
||||
}
|
||||
case FROM_LEGACY_ENV_VAR -> {
|
||||
String varName = descriptor.envVarName().orElse("unbekannte Legacy-ENV-Variable");
|
||||
findings.add(EditorValidationFinding.info(fieldKey,
|
||||
"API-Schlüssel stammt aus Legacy-Umgebungsvariable " + varName
|
||||
+ " (hat Vorrang vor dem Datei-Wert)."));
|
||||
}
|
||||
case FROM_PROPERTY_FILE -> {
|
||||
// Property-Wert vorhanden, kein ENV-Override: alles in Ordnung, kein Befund
|
||||
}
|
||||
case ABSENT -> {
|
||||
findings.add(EditorValidationFinding.warning(fieldKey,
|
||||
"Kein API-Schlüssel hinterlegt. Ohne Schlüssel kann der Provider "
|
||||
+ "nicht genutzt werden."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Ein einzelner Validierungsbefund für den aktuellen Editorzustand.
|
||||
* <p>
|
||||
* Ein Befund beschreibt eine erkannte Auffälligkeit in der Konfiguration mit einem
|
||||
* Schweregrad, einem Feldbezug und einer deutschen Beschreibung. Das {@code fieldKey}-Feld
|
||||
* verwendet denselben Property-Schlüssel wie die {@code .properties}-Datei
|
||||
* (z. B. {@code "source.folder"} oder {@code "ai.provider.openai-compatible.apiKey"}).
|
||||
* <p>
|
||||
* Ein Befund kann entweder feld-spezifisch sein (mit gesetztem {@code fieldKey}) oder
|
||||
* allgemein (mit leerem {@code fieldKey}), wenn er sich nicht auf ein einzelnes Feld bezieht.
|
||||
* <p>
|
||||
* Befunde sind immutable und enthalten keine JavaFX-Typen.
|
||||
*
|
||||
* @param fieldKey optionaler Property-Schlüssel des betroffenen Felds; leer wenn feld-unabhängig
|
||||
* @param severity Schweregrad des Befunds; nie {@code null}
|
||||
* @param message deutschsprachige Beschreibung; nie {@code null}
|
||||
*/
|
||||
public record EditorValidationFinding(
|
||||
Optional<String> fieldKey,
|
||||
EditorValidationSeverity severity,
|
||||
String message) {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Validierungsbefund.
|
||||
*
|
||||
* @param fieldKey optionaler Property-Schlüssel; {@code null} wird zu {@link Optional#empty()}
|
||||
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Beschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code severity} oder {@code message} {@code null} sind
|
||||
*/
|
||||
public EditorValidationFinding {
|
||||
Objects.requireNonNull(severity, "severity must not be null");
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
fieldKey = fieldKey == null ? Optional.empty() : fieldKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Fehler-Befund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Fehlerbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#ERROR}
|
||||
*/
|
||||
public static EditorValidationFinding error(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.ERROR, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Warn-Befund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Warnbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#WARNING}
|
||||
*/
|
||||
public static EditorValidationFinding warning(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.WARNING, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Hinweis-Befund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Hinweisbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#HINT}
|
||||
*/
|
||||
public static EditorValidationFinding hint(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.HINT, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen allgemeinen Informationsbefund ohne Feldbezug.
|
||||
*
|
||||
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
|
||||
*/
|
||||
public static EditorValidationFinding info(String message) {
|
||||
return new EditorValidationFinding(Optional.empty(), EditorValidationSeverity.INFO, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen feldbezogenen Informationsbefund.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
|
||||
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
|
||||
*/
|
||||
public static EditorValidationFinding info(String fieldKey, String message) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.INFO, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob dieser Befund feld-spezifisch ist.
|
||||
*
|
||||
* @return {@code true} wenn ein {@code fieldKey} gesetzt ist
|
||||
*/
|
||||
public boolean hasFieldKey() {
|
||||
return fieldKey.isPresent();
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
|
||||
/**
|
||||
* Eingabedaten für den {@link EditorConfigurationValidator}.
|
||||
* <p>
|
||||
* Enthält den gesamten aktuellen Editorzustand als String-Werte, so wie sie im Editor
|
||||
* vorliegen – ohne Parsing, Typumwandlung oder Existenzprüfungen. Der Validator bewertet
|
||||
* ausschließlich diese Werte.
|
||||
* <p>
|
||||
* Pfad-Existenzprüfungen und Roundtrips (SQLite, Prompt-Datei, Netzwerk) sind explizit
|
||||
* ausgeschlossen und gehören in spätere technische Gesamtprüfungen.
|
||||
* <p>
|
||||
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
||||
*
|
||||
* @param activeProviderIdentifier Rohtextwert von {@code ai.provider.active}
|
||||
* @param sourceFolder Rohtextwert von {@code source.folder}
|
||||
* @param targetFolder Rohtextwert von {@code target.folder}
|
||||
* @param sqliteFile Rohtextwert von {@code sqlite.file}
|
||||
* @param promptTemplateFile Rohtextwert von {@code prompt.template.file}
|
||||
* @param maxRetriesTransient Rohtextwert von {@code max.retries.transient}
|
||||
* @param maxPages Rohtextwert von {@code max.pages}
|
||||
* @param maxTextCharacters Rohtextwert von {@code max.text.characters}
|
||||
* @param claudeBaseUrl Rohtextwert der Claude-Basis-URL
|
||||
* @param claudeModel Rohtextwert des Claude-Modellnamens
|
||||
* @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts
|
||||
* @param claudeApiKeyDescriptor API-Key-Herkunft für den Claude-Provider; nie {@code null}
|
||||
* @param openaiBaseUrl Rohtextwert der OpenAI-kompatiblen Basis-URL
|
||||
* @param openaiModel Rohtextwert des OpenAI-kompatiblen Modellnamens
|
||||
* @param openaiTimeoutSeconds Rohtextwert des OpenAI-kompatiblen Timeouts
|
||||
* @param openaiApiKeyDescriptor API-Key-Herkunft für den OpenAI-kompatiblen Provider; nie {@code null}
|
||||
*/
|
||||
public record EditorValidationInput(
|
||||
String activeProviderIdentifier,
|
||||
String sourceFolder,
|
||||
String targetFolder,
|
||||
String sqliteFile,
|
||||
String promptTemplateFile,
|
||||
String maxRetriesTransient,
|
||||
String maxPages,
|
||||
String maxTextCharacters,
|
||||
String claudeBaseUrl,
|
||||
String claudeModel,
|
||||
String claudeTimeoutSeconds,
|
||||
EffectiveApiKeyDescriptor claudeApiKeyDescriptor,
|
||||
String openaiBaseUrl,
|
||||
String openaiModel,
|
||||
String openaiTimeoutSeconds,
|
||||
EffectiveApiKeyDescriptor openaiApiKeyDescriptor) {
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Eingabe für den Validator.
|
||||
*
|
||||
* @param activeProviderIdentifier aktiver Provider-Bezeichner; {@code null} wird zu leerem String
|
||||
* @param sourceFolder Quellordner-Pfad; {@code null} wird zu leerem String
|
||||
* @param targetFolder Zielordner-Pfad; {@code null} wird zu leerem String
|
||||
* @param sqliteFile SQLite-Dateipfad; {@code null} wird zu leerem String
|
||||
* @param promptTemplateFile Prompt-Dateipfad; {@code null} wird zu leerem String
|
||||
* @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 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
|
||||
* @param claudeApiKeyDescriptor Claude-API-Key-Herkunft; darf nicht {@code null} sein
|
||||
* @param openaiBaseUrl OpenAI-Basis-URL; {@code null} wird zu leerem String
|
||||
* @param openaiModel OpenAI-Modellname; {@code null} wird zu leerem String
|
||||
* @param openaiTimeoutSeconds OpenAI-Timeout; {@code null} wird zu leerem String
|
||||
* @param openaiApiKeyDescriptor OpenAI-API-Key-Herkunft; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code claudeApiKeyDescriptor} oder {@code openaiApiKeyDescriptor} {@code null} sind
|
||||
*/
|
||||
public EditorValidationInput {
|
||||
activeProviderIdentifier = normalizeText(activeProviderIdentifier);
|
||||
sourceFolder = normalizeText(sourceFolder);
|
||||
targetFolder = normalizeText(targetFolder);
|
||||
sqliteFile = normalizeText(sqliteFile);
|
||||
promptTemplateFile = normalizeText(promptTemplateFile);
|
||||
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
||||
maxPages = normalizeText(maxPages);
|
||||
maxTextCharacters = normalizeText(maxTextCharacters);
|
||||
claudeBaseUrl = normalizeText(claudeBaseUrl);
|
||||
claudeModel = normalizeText(claudeModel);
|
||||
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
|
||||
claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor,
|
||||
"claudeApiKeyDescriptor must not be null");
|
||||
openaiBaseUrl = normalizeText(openaiBaseUrl);
|
||||
openaiModel = normalizeText(openaiModel);
|
||||
openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds);
|
||||
openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor,
|
||||
"openaiApiKeyDescriptor must not be null");
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis einer editornahen Konfigurationsvalidierung.
|
||||
* <p>
|
||||
* Enthält alle Befunde, die der {@link EditorConfigurationValidator} aus dem aktuellen
|
||||
* Editorzustand abgeleitet hat. Das Ergebnis ist immutable.
|
||||
* <p>
|
||||
* Befunde können über {@link #findings()} als vollständige Liste abgerufen werden.
|
||||
* Feldbezogene Befunde lassen sich über {@link EditorValidationFinding#hasFieldKey()} filtern.
|
||||
*
|
||||
* @param findings alle Validierungsbefunde; nie {@code null}
|
||||
*/
|
||||
public record EditorValidationReport(List<EditorValidationFinding> findings) {
|
||||
|
||||
/**
|
||||
* Erstellt ein Validierungsergebnis.
|
||||
*
|
||||
* @param findings Befundliste; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code findings} {@code null} ist
|
||||
*/
|
||||
public EditorValidationReport {
|
||||
Objects.requireNonNull(findings, "findings must not be null");
|
||||
findings = List.copyOf(findings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein leeres Ergebnis ohne Befunde.
|
||||
*
|
||||
* @return ein leeres Ergebnis; nie {@code null}
|
||||
*/
|
||||
public static EditorValidationReport empty() {
|
||||
return new EditorValidationReport(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens ein Befund mit Schweregrad {@link EditorValidationSeverity#ERROR} vorhanden ist.
|
||||
* <p>
|
||||
* Wenn {@code true}, gilt die Konfiguration als nicht lauffähig. Das Speichern ist jedoch
|
||||
* trotzdem erlaubt.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein Fehler-Befund vorliegt
|
||||
*/
|
||||
public boolean hasErrors() {
|
||||
return findings.stream().anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Befunde zurück, die sich auf das angegebene Feld beziehen.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel des gesuchten Felds; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der feldbezogenen Befunde; nie {@code null}
|
||||
*/
|
||||
public List<EditorValidationFinding> findingsFor(String fieldKey) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return findings.stream()
|
||||
.filter(f -> f.fieldKey().isPresent() && f.fieldKey().get().equals(fieldKey))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
|
||||
/**
|
||||
* Schweregrade für Befunde der editornahen Konfigurationsvalidierung.
|
||||
* <p>
|
||||
* Die Reihenfolge entspricht aufsteigender Kritikalität:
|
||||
* <ol>
|
||||
* <li>{@link #INFO} – neutraler Informationshinweis, keine Handlung erforderlich</li>
|
||||
* <li>{@link #HINT} – nützlicher Hinweis, den der Benutzer berücksichtigen sollte</li>
|
||||
* <li>{@link #WARNING} – riskante, aber formal zulässige Einstellung</li>
|
||||
* <li>{@link #ERROR} – ungültige oder fehlende Pflichtangabe, Konfiguration nicht lauffähig</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Warnungen und Hinweise verhindern das Speichern nicht. Fehler markieren den Stand als
|
||||
* nicht lauffähig, erlauben aber ebenfalls das Speichern.
|
||||
*/
|
||||
public enum EditorValidationSeverity {
|
||||
|
||||
/** Neutraler Informationshinweis. */
|
||||
INFO,
|
||||
|
||||
/** Nützlicher Hinweis, den der Benutzer beachten sollte. */
|
||||
HINT,
|
||||
|
||||
/** Riskante, aber formal zulässige Einstellung. */
|
||||
WARNING,
|
||||
|
||||
/** Ungültige oder fehlende Pflichtangabe – Konfiguration ist nicht lauffähig. */
|
||||
ERROR
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Editornahe Validierungskomponenten für den Konfigurationseditor.
|
||||
* <p>
|
||||
* Dieses Package enthält den zentralen {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator},
|
||||
* der den aktuellen Editorzustand gegen fachliche und technische Regeln prüft und
|
||||
* Befunde der Stufen Fehler, Warnung, Hinweis und Info erzeugt.
|
||||
* <p>
|
||||
* Die Komponenten sind infrastrukturneutral: Sie kennen keine JavaFX-Typen, keine
|
||||
* Dateisystempfade, keine Datenbankzugriffe und keine HTTP-Kommunikation. Alle Prüfungen
|
||||
* arbeiten ausschließlich auf den übergebenen String-Werten des aktuellen Editorzustands.
|
||||
* <p>
|
||||
* Die Ergebnistypen ({@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding}
|
||||
* und {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport}) sind immutable
|
||||
* Records und enthalten keine Infrastrukturabhängigkeiten.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||
Reference in New Issue
Block a user