diff --git a/docs/betrieb.md b/docs/betrieb.md index c667531..48ac901 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext. ### Umfang der GUI -Die GUI enthält zwei Tabs: +Die GUI enthält drei Tabs: - **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei, @@ -75,6 +75,13 @@ Die GUI enthält zwei Tabs: ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop** über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei. Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin. +- **Tab „Prompt"** – Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt + aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel). + Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`). + Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage, + ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein + „Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch + geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus. Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau @@ -292,6 +299,35 @@ Die Anwendung ergänzt den Prompt automatisch um: - einen Dokumenttext-Abschnitt - eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date` +### Prompt-Pfad-Auflösung je Betriebsart + +Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst, +wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab: + +| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert | +|---|---|---| +| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` | +| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` | +| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` | +| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen | + +> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad** +> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis +> immer eindeutig auffindbar – insbesondere beim Start über den Windows Task Scheduler, +> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann. + +### Bearbeitung über den GUI-Prompt-Tab + +Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und +gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn +das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem +Fall wird kein stiller Fallback durchgeführt). + +Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert +war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"), +aktualisiert sich der Prompt-Tab nicht automatisch – in diesem Fall sollte die GUI neu gestartet +oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden. + --- ## Zielformat diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 5e0f9f5..b820ef6 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -414,6 +414,13 @@ public final class GuiConfigurationEditorWorkspace { */ private final GuiHistoricalDocumentContextPort historicalDocumentContextPort; + /** + * Bridge-Port zum Prompt-Editor-Use-Case. Wird vom {@link GuiPromptEditorTab} genutzt, + * um den Prompt-Inhalt zu laden, zu speichern und eine Standard-Prompt-Datei anzulegen. + * Supplied by Bootstrap via the startup context. + */ + private final GuiPromptEditorPort promptEditorPort; + /** * Second main tab of the window that drives the live processing-run view. Created * during workspace construction and wired into the shared {@link #tabPane} alongside @@ -421,6 +428,12 @@ public final class GuiConfigurationEditorWorkspace { */ private final GuiBatchRunTab batchRunTab; + /** + * Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt + * und in den {@link #tabPane} eingehängt. + */ + private final GuiPromptEditorTab promptEditorTab; + /** * Hint banner shown at the top of the configuration tab while a processing run is * active. Visible + managed state are flipped from the batch run tab's listener when @@ -491,6 +504,7 @@ public final class GuiConfigurationEditorWorkspace { this.manualFileRenamePort = effectiveContext.manualFileRenamePort(); this.manualFileCopyPort = effectiveContext.manualFileCopyPort(); this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort(); + this.promptEditorPort = effectiveContext.promptEditorPort(); this.batchRunTab = new GuiBatchRunTab( () -> this.batchRunLauncher, () -> this.miniRunLauncher, @@ -504,6 +518,17 @@ public final class GuiConfigurationEditorWorkspace { this::editorSourceFolder, this::editorTargetFolder); + String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile(); + int maxTitleLength; + try { + maxTitleLength = Integer.parseInt( + effectiveContext.initialState().values().maxTitleLength().trim()); + } catch (NumberFormatException e) { + maxTitleLength = 60; + } + this.promptEditorTab = new GuiPromptEditorTab( + this.promptEditorPort, configuredPromptPath, maxTitleLength); + configureRoot(); configureHeader(effectiveContext.startupNotice()); configureTabs(); @@ -1271,11 +1296,12 @@ public final class GuiConfigurationEditorWorkspace { scrollPane.setPadding(new Insets(0)); editorTab.setContent(scrollPane); - tabPane.getTabs().setAll(editorTab, batchRunTab.tab()); + tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab()); root.setCenter(tabPane); // Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob // der Dateiname-Editor ungespeicherte Änderungen hat. + // Gleiches gilt für den Prompt-Tab. tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> { if (oldTab == null || newTab == null) { return; @@ -1287,6 +1313,11 @@ public final class GuiConfigurationEditorWorkspace { // Zurück zum Verarbeitungslauf-Tab Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab)); } + } else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) { + boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty(); + if (!shouldDiscard) { + Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab)); + } } }); } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorPort.java new file mode 100644 index 0000000..358f6a2 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorPort.java @@ -0,0 +1,64 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; + +/** + * GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen + * Use-Case in der Application-Schicht. + *
+ * Dieses Interface ist kein hexagonaler Outbound-Port der Application-Schicht. + * Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte + * Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf + * {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem + * zugreift. + *
+ * Verantwortung: + *
+ * Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}. + * Das GUI-Modul kennt ausschließlich den Interface-Typ. + */ +public interface GuiPromptEditorPort { + + /** + * Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle. + *
+ * Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via + * {@code Platform.runLater} in den JavaFX Application Thread übergeben. + * + * @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg, + * oder einem klassifizierten Fehler; nie {@code null} + */ + PromptLoadingResult loadCurrentPrompt(); + + /** + * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei. + *
+ * Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via + * {@code Platform.runLater} in den JavaFX Application Thread übergeben. + * + * @param content der zu speichernde Inhalt; darf nicht {@code null} sein + * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null} + * @throws NullPointerException wenn {@code content} null ist + */ + PromptSaveResult save(String content); + + /** + * Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist. + *
+ * Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via + * {@code Platform.runLater} in den JavaFX Application Thread übergeben. + * + * @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein + * @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null} + * @throws NullPointerException wenn {@code suggestion} null ist + */ + CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTab.java new file mode 100644 index 0000000..94560e7 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTab.java @@ -0,0 +1,365 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TextArea; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; + +/** + * Tab „Prompt" im Hauptfenster des GUI-Adapters. + *
+ * Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei + * direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen. + *
+ * Verhalten: + *
+ * Threading: Alle blockierenden Operationen (Laden, Speichern,
+ * Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
+ * ausschließlich via {@code Platform.runLater}.
+ */
+public class GuiPromptEditorTab {
+
+ private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
+
+ private static final String TAB_TITLE = "Prompt";
+ private static final String TAB_TITLE_DIRTY = "Prompt *";
+
+ private final GuiPromptEditorPort promptEditorPort;
+ /** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
+ private final String configuredPromptPath;
+ /** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
+ private final int maxTitleLength;
+
+ // Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
+ /** Erzeugt Worker-Threads für blockierende Operationen. */
+ Function
+ * Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
+ * läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
+ */
+ public void loadPromptAsync() {
+ setStatus("Lade Prompt-Datei ...");
+ saveButton.setDisable(true);
+ resetButton.setDisable(true);
+ createDefaultButton.setVisible(false);
+ createDefaultButton.setManaged(false);
+
+ Thread worker = threadFactory.apply(() -> {
+ var result = promptEditorPort.loadCurrentPrompt();
+ fxDispatcher.accept(() -> applyLoadResult(result));
+ });
+ worker.start();
+ }
+
+ // -------------------------------------------------------------------------
+ // Private Aufbau
+ // -------------------------------------------------------------------------
+
+ private void buildTab() {
+ tab.setClosable(false);
+
+ // TextArea – monospace Font für bessere Lesbarkeit
+ textArea.setWrapText(true);
+ textArea.setFont(Font.font("Monospace", 13));
+ textArea.setPrefRowCount(20);
+ VBox.setVgrow(textArea, Priority.ALWAYS);
+
+ // Dirty-State-Tracking
+ textArea.textProperty().addListener((obs, oldVal, newVal) -> {
+ if (loadedContent != null) {
+ boolean nowDirty = !newVal.equals(loadedContent);
+ if (nowDirty != dirty) {
+ dirty = nowDirty;
+ tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
+ }
+ }
+ });
+
+ // Status-Label
+ statusLabel.setWrapText(true);
+ statusLabel.setStyle("-fx-text-fill: #555555;");
+
+ // Buttons verdrahten
+ saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8)."));
+ saveButton.setOnAction(e -> requestSave());
+
+ resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern."));
+ resetButton.setOnAction(e -> resetToDefault());
+
+ createDefaultButton.setTooltip(new Tooltip(
+ "Standard-Prompt-Datei am konfigurierten Pfad anlegen."));
+ createDefaultButton.setOnAction(e -> requestCreateDefault());
+ createDefaultButton.setVisible(false);
+ createDefaultButton.setManaged(false);
+
+ HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
+ buttonBar.setAlignment(Pos.CENTER_LEFT);
+ buttonBar.setPadding(new Insets(6, 0, 0, 0));
+
+ VBox content = new VBox(6, textArea, statusLabel, buttonBar);
+ content.setPadding(new Insets(12));
+ VBox.setVgrow(textArea, Priority.ALWAYS);
+
+ BorderPane root = new BorderPane(content);
+ tab.setContent(root);
+
+ // Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
+ tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
+ if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
+ loadPromptAsync();
+ }
+ });
+ }
+
+ private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
+ if (result instanceof PromptLoadingSuccess success) {
+ loadedContent = success.promptContent();
+ textArea.setText(loadedContent);
+ textArea.setEditable(true);
+ saveButton.setDisable(false);
+ resetButton.setDisable(false);
+ createDefaultButton.setVisible(false);
+ createDefaultButton.setManaged(false);
+ setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
+ dirty = false;
+ tab.setText(TAB_TITLE);
+ LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
+ success.promptIdentifier().identifier());
+ } else if (result instanceof PromptLoadingFailure failure) {
+ boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
+ if (fileNotFound) {
+ // Datei fehlt – Hinweis und Anlegen-Button anzeigen
+ loadedContent = null;
+ textArea.setEditable(false);
+ textArea.clear();
+ saveButton.setDisable(true);
+ resetButton.setDisable(false);
+ createDefaultButton.setVisible(true);
+ createDefaultButton.setManaged(true);
+ setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
+ + "konfigurieren Sie den Pfad im Konfigurationstab.");
+ LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
+ } else {
+ // Anderer Fehler (I/O, leer usw.)
+ loadedContent = null;
+ textArea.setEditable(false);
+ textArea.clear();
+ saveButton.setDisable(true);
+ resetButton.setDisable(false);
+ createDefaultButton.setVisible(false);
+ createDefaultButton.setManaged(false);
+ setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
+ LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
+ failure.failureReason(), failure.failureMessage());
+ }
+ }
+ }
+
+ private void requestSave() {
+ String currentText = textArea.getText();
+
+ // Leerer Prompt: Bestätigungsdialog
+ if (currentText.trim().isEmpty()) {
+ Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
+ confirm.setTitle("Leerer Prompt");
+ confirm.setHeaderText("Der Prompt ist leer.");
+ confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
+ confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
+ Optional
+ * Geprüfte Szenarien:
+ *
- * Loads prompt templates from an external file on disk and derives a stable identifier
- * from the filename. Ensures that empty or technically unusable prompts are rejected.
+ * Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
+ * stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
+ * unbrauchbare Prompts abgelehnt werden.
*
- * Identifier derivation:
- * The stable prompt identifier is derived from the filename of the prompt file.
- * This ensures deterministic, reproducible identification across batch runs.
- * For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
+ * Identifikatorableitung:
+ * Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
+ * Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
* {@code "prompt_de_v2.txt"}.
*
- * Content validation:
- * After loading, the prompt content is trimmed and validated to ensure it is not empty.
- * An empty prompt (or one containing only whitespace) is considered technically unusable
- * and results in a {@link PromptLoadingFailure}.
+ * Inhaltsprüfung:
+ * Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
+ * (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
+ * {@link PromptLoadingFailure}.
*
- * Error handling:
- * All technical failures (file not found, I/O errors, permission issues) are caught
- * and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
+ * Atomares Speichern:
+ * {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei im selben
+ * Verzeichnis wie die Zieldatei und verschiebt diese danach atomar via
+ * {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
+ * Fallback auf nicht-atomares Schreiben durchgeführt.
+ *
+ * Fehlerbehandlung:
+ * Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
+ * werden abgefangen und als strukturierte Ergebnistypen zurückgegeben – keine Exceptions
+ * werden propagiert.
*/
public class FilesystemPromptPortAdapter implements PromptPort {
@@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort {
private final Path promptFilePath;
/**
- * Creates the adapter with the configured prompt file path.
+ * Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
*
- * @param promptFilePath the path to the prompt template file; must not be null
- * @throws NullPointerException if promptFilePath is null
+ * @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code promptFilePath} null ist
*/
public FilesystemPromptPortAdapter(Path promptFilePath) {
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
}
+ /**
+ * Lädt das konfigurierte Prompt-Template aus der Datei.
+ *
+ * @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
+ * nie {@code null}
+ */
@Override
public PromptLoadingResult loadPrompt() {
try {
@@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
PromptIdentifier identifier = deriveIdentifier();
- LOG.debug("Prompt loaded successfully from {}", promptFilePath);
+ LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
return new PromptLoadingSuccess(identifier, trimmedContent);
} catch (IOException e) {
- LOG.error("Failed to load prompt file: {}", promptFilePath, e);
+ LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
return new PromptLoadingFailure(
"IO_ERROR",
"Failed to read prompt file: " + e.getMessage());
@@ -83,15 +100,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
/**
- * Derives a stable prompt identifier from the filename.
+ * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
*
- * The identifier is simply the filename (without the directory path).
- * This ensures that the same prompt file always receives the same identifier.
+ * Der Ablauf:
+ *
+ * Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
*
- * @return a stable PromptIdentifier based on the filename
+ * @param content der zu speichernde Inhalt; darf nicht {@code null} sein
+ * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
+ * @throws NullPointerException wenn {@code content} null ist
+ */
+ @Override
+ public PromptSaveResult savePrompt(String content) {
+ Objects.requireNonNull(content, "content must not be null");
+
+ Path targetDir = promptFilePath.getParent();
+ if (targetDir == null || !Files.isDirectory(targetDir)) {
+ String message = "Zielordner der Prompt-Datei existiert nicht: "
+ + (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
+ LOG.warn("Prompt speichern fehlgeschlagen: {}", message);
+ return new PromptSaveResult.TargetDirectoryMissing(message);
+ }
+
+ // Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
+ // (nicht im System-Temp – ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
+ Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
+
+ try {
+ Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ beräumeTempDatei(tempFile);
+ String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
+ LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
+ return new PromptSaveResult.WriteFailed(message, e);
+ }
+
+ // Atomares Verschieben – kein stiller Fallback auf nicht-atomares Move
+ try {
+ Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
+ return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
+ } catch (AtomicMoveNotSupportedException e) {
+ beräumeTempDatei(tempFile);
+ String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
+ LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
+ return new PromptSaveResult.AtomicMoveFailed(message);
+ } catch (IOException e) {
+ beräumeTempDatei(tempFile);
+ String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
+ LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
+ return new PromptSaveResult.AtomicMoveFailed(message);
+ }
+ }
+
+ /**
+ * Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
+ *
+ * Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
+ *
+ * @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
*/
private PromptIdentifier deriveIdentifier() {
String filename = promptFilePath.getFileName().toString();
return new PromptIdentifier(filename);
}
+
+ /**
+ * Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
+ *
+ * @param tempFile die zu löschende temporäre Datei
+ */
+ private void beräumeTempDatei(Path tempFile) {
+ try {
+ Files.deleteIfExists(tempFile);
+ } catch (IOException ex) {
+ LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
+ }
+ }
}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java
index 609df91..cd231bf 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
/**
* Unit tests for {@link FilesystemPromptPortAdapter}.
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
}
+
+ // -------------------------------------------------------------------------
+ // savePrompt tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
+ // Given
+ Path promptFile = tempDir.resolve("prompt_save.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+ String content = "Mein Prompt-Inhalt";
+
+ // When
+ PromptSaveResult result = adapter.savePrompt(content);
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
+ PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
+ assertThat(saved.absolutePath()).contains("prompt_save.txt");
+ assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
+ }
+
+ @Test
+ void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
+ // Given
+ Path promptFile = tempDir.resolve("prompt_umlaut.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+ String content = "Ärger mit Überschriften und Schluß";
+
+ // When
+ PromptSaveResult result = adapter.savePrompt(content);
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
+ assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
+ }
+
+ @Test
+ void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
+ // Given
+ Path promptFile = tempDir.resolve("prompt_lineendings.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+ String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
+
+ // When
+ PromptSaveResult result = adapter.savePrompt(content);
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
+ byte[] raw = Files.readAllBytes(promptFile);
+ assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
+ }
+
+ @Test
+ void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
+ // Given
+ Path promptFile = tempDir.resolve("prompt_overwrite.txt");
+ Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+ String newContent = "Neuer Inhalt";
+
+ // When
+ PromptSaveResult result = adapter.savePrompt(newContent);
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
+ assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
+ }
+
+ @Test
+ void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
+ // Given
+ Path nonExistentDir = tempDir.resolve("missing-subdir");
+ Path promptFile = nonExistentDir.resolve("prompt.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+
+ // When
+ PromptSaveResult result = adapter.savePrompt("Inhalt");
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
+ PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
+ assertThat(missing.message()).contains("missing-subdir");
+ }
+
+ @Test
+ void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
+ // Given
+ Path promptFile = tempDir.resolve("prompt_null.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+
+ // When & Then
+ assertThatThrownBy(() -> adapter.savePrompt(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("content must not be null");
+ }
+
+ @Test
+ void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
+ // Given – Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
+ Path nonExistentDir = tempDir.resolve("ghost-dir");
+ Path promptFile = nonExistentDir.resolve("prompt.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+
+ // When
+ PromptSaveResult result = adapter.savePrompt("Inhalt");
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
+ // Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
+ assertThat(nonExistentDir).doesNotExist();
+ }
+
+ @Test
+ void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
+ // Given
+ Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
+ adapter = new FilesystemPromptPortAdapter(promptFile);
+ String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
+
+ // When
+ PromptSaveResult saveResult = adapter.savePrompt(content);
+ PromptLoadingResult loadResult = adapter.loadPrompt();
+
+ // Then
+ assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
+ assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
+ PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
+ // loadPrompt trims the content; trim the expected too
+ assertThat(success.promptContent()).isEqualTo(content.trim());
+ }
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java
index 3718081..8080a59 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java
@@ -1,56 +1,76 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
- * Outbound port for loading external prompt templates.
+ * Outbound-Port zum Laden und Speichern des externen Prompt-Templates.
*
- * This interface abstracts the loading of prompt content from external sources
- * (files, resources, databases, etc.), allowing the Application layer to remain
- * independent of how or where prompts are stored.
+ * Dieses Interface abstrahiert den Zugriff auf die Prompt-Datei und erlaubt der
+ * Application-Schicht, unabhängig vom konkreten Speichermedium zu bleiben.
*
- * Design principles:
+ * Designprinzipien:
*
- * Adapter responsibilities:
+ * Adapter-Verantwortung:
*
- * Non-goals of this port:
+ * Nicht-Ziele dieses Ports:
*
- * This method is called once per batch run to obtain the current prompt.
- * The prompt content and its stable identifier are returned together.
+ * Diese Methode wird einmal pro Verarbeitungslauf aufgerufen, um den aktuellen Prompt zu laden.
+ * Inhalt und stabiler Identifikator werden gemeinsam zurückgegeben.
*
- * If loading fails for any reason (file not found, I/O error, content validation),
- * a {@link PromptLoadingFailure} is returned rather than throwing an exception.
- *
- * @return a {@link PromptLoadingResult} encoding either:
- *
+ * Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist
+ * nicht Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen
+ * im Port-Vertrag).
+ *
+ * Die Implementierung schreibt zunächst in eine temporäre Datei im selben Verzeichnis
+ * wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}.
+ * Bei einem Fehler beim atomaren Verschieben wird kein stiller Fallback
+ * auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird
+ * {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben.
+ *
+ * Zeichenkodierung: UTF-8. Zeilenenden werden unverändert übernommen.
+ *
+ * @param content der zu speichernde Prompt-Inhalt; darf leer sein (Entscheidung liegt
+ * beim Aufrufer, ob ein leerer Prompt erwünscht ist)
+ * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
+ * @throws NullPointerException wenn {@code content} null ist
+ * @see PromptSaveResult.Saved
+ * @see PromptSaveResult.WriteFailed
+ * @see PromptSaveResult.TargetDirectoryMissing
+ * @see PromptSaveResult.AtomicMoveFailed
+ */
+ PromptSaveResult savePrompt(String content);
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptSaveResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptSaveResult.java
new file mode 100644
index 0000000..f07efe2
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptSaveResult.java
@@ -0,0 +1,96 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Versiegeltes Ergebnis-Interface für das Speichern einer Prompt-Datei via
+ * {@link PromptPort#savePrompt(String)}.
+ *
+ * Mögliche Ergebnisse:
+ *
+ * Dieser Use-Case vermittelt zwischen dem GUI-Adapter und dem {@link PromptPort} sowie dem
+ * {@link ResourceCreationPort}. Er kennt keine JavaFX-Typen, kein Dateisystem und keine
+ * HTTP-Kommunikation; alle technischen Details bleiben in den jeweiligen Adaptern.
+ *
+ * Verantwortung:
+ *
+ * Abgrenzung: Dieser Use-Case trifft keine Entscheidungen über
+ * Benutzeroberfläche, Threading oder Dirty-State-Verwaltung. Diese Verantwortung
+ * liegt im GUI-Adapter.
+ */
+public class DefaultPromptEditorUseCase {
+
+ private final PromptPort promptPort;
+ private final ResourceCreationPort resourceCreationPort;
+
+ /**
+ * Erstellt den Use-Case mit den erforderlichen Ports.
+ *
+ * @param promptPort Port zum Laden und Speichern des Prompt-Templates;
+ * darf nicht {@code null} sein
+ * @param resourceCreationPort Port zum Anlegen der Standard-Prompt-Datei;
+ * darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ */
+ public DefaultPromptEditorUseCase(PromptPort promptPort, ResourceCreationPort resourceCreationPort) {
+ this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null");
+ this.resourceCreationPort = Objects.requireNonNull(resourceCreationPort,
+ "resourceCreationPort must not be null");
+ }
+
+ /**
+ * Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Prompt-Datei.
+ *
+ * Delegiert direkt an {@link PromptPort#loadPrompt()} und gibt das Ergebnis
+ * unverändert zurück.
+ *
+ * @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
+ * oder einem klassifizierten Fehler; nie {@code null}
+ * @see PromptLoadingSuccess
+ * @see PromptLoadingFailure
+ */
+ public PromptLoadingResult loadPrompt() {
+ return promptPort.loadPrompt();
+ }
+
+ /**
+ * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
+ *
+ * Delegiert direkt an {@link PromptPort#savePrompt(String)}. Der Zielpfad ist
+ * Implementierungsdetail des Adapters.
+ *
+ * @param content der zu speichernde Prompt-Inhalt; darf nicht {@code null} sein
+ * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
+ * @throws NullPointerException wenn {@code content} null ist
+ * @see PromptSaveResult.Saved
+ * @see PromptSaveResult.WriteFailed
+ * @see PromptSaveResult.TargetDirectoryMissing
+ * @see PromptSaveResult.AtomicMoveFailed
+ */
+ public PromptSaveResult savePrompt(String content) {
+ Objects.requireNonNull(content, "content must not be null");
+ return promptPort.savePrompt(content);
+ }
+
+ /**
+ * Legt eine Standard-Prompt-Datei an, wenn noch keine vorhanden ist.
+ *
+ * Delegiert an {@link ResourceCreationPort#createPromptFile(CorrectionSuggestion.CreatePromptFile)}.
+ * Das Ergebnis beschreibt, ob die Datei angelegt wurde, ob sie bereits existierte
+ * oder ob ein Fehler aufgetreten ist.
+ *
+ * @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
+ * @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
+ * @throws NullPointerException wenn {@code suggestion} null ist
+ */
+ public CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion) {
+ Objects.requireNonNull(suggestion, "suggestion must not be null");
+ return resourceCreationPort.createPromptFile(suggestion);
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
index 5521bde..f51cd90 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
@@ -1062,8 +1062,16 @@ class BatchRunProcessingUseCaseTest {
private static AiNamingService buildStubAiNamingService() {
AiInvocationPort stubAiPort = request ->
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
- PromptPort stubPromptPort = () ->
- new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
+ PromptPort stubPromptPort = new PromptPort() {
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
+ return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
+ }
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
+ return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
+ }
+ };
ClockPort stubClock = () -> java.time.Instant.EPOCH;
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java
index 59bce43..5108cf6 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java
@@ -279,8 +279,16 @@ class BatchRunProgressObservationTest {
AiInvocationPort stubAi = req -> {
throw new IllegalStateException("AI must not be invoked in these tests");
};
- PromptPort stubPrompt = () -> new PromptLoadingSuccess(
- new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
+ PromptPort stubPrompt = new PromptPort() {
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
+ return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
+ }
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
+ return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
+ }
+ };
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCaseTest.java
new file mode 100644
index 0000000..8c85b41
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCaseTest.java
@@ -0,0 +1,210 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
+import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
+
+/**
+ * Unit-Tests für {@link DefaultPromptEditorUseCase}.
+ *
+ * Prüft die Delegation an {@link PromptPort} und {@link ResourceCreationPort}
+ * sowie die Null-Prüfungen am Konstruktor und an den Methoden.
+ */
+class DefaultPromptEditorUseCaseTest {
+
+ private static final String STUB_CONTENT = "Mein Test-Prompt";
+ private static final PromptIdentifier STUB_IDENTIFIER = new PromptIdentifier("test-prompt.txt");
+
+ private StubPromptPort stubPromptPort;
+ private StubResourceCreationPort stubResourceCreationPort;
+ private DefaultPromptEditorUseCase useCase;
+
+ @BeforeEach
+ void setUp() {
+ stubPromptPort = new StubPromptPort();
+ stubResourceCreationPort = new StubResourceCreationPort();
+ useCase = new DefaultPromptEditorUseCase(stubPromptPort, stubResourceCreationPort);
+ }
+
+ // -------------------------------------------------------------------------
+ // Konstruktor
+ // -------------------------------------------------------------------------
+
+ @Test
+ void constructor_shouldThrowNullPointerException_whenPromptPortIsNull() {
+ assertThatThrownBy(() -> new DefaultPromptEditorUseCase(null, stubResourceCreationPort))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("promptPort");
+ }
+
+ @Test
+ void constructor_shouldThrowNullPointerException_whenResourceCreationPortIsNull() {
+ assertThatThrownBy(() -> new DefaultPromptEditorUseCase(stubPromptPort, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("resourceCreationPort");
+ }
+
+ // -------------------------------------------------------------------------
+ // loadPrompt
+ // -------------------------------------------------------------------------
+
+ @Test
+ void loadPrompt_shouldDelegateToPromptPort_andReturnSuccess() {
+ // Given
+ stubPromptPort.loadResult = new PromptLoadingSuccess(STUB_IDENTIFIER, STUB_CONTENT);
+
+ // When
+ PromptLoadingResult result = useCase.loadPrompt();
+
+ // Then
+ assertThat(result).isInstanceOf(PromptLoadingSuccess.class);
+ PromptLoadingSuccess success = (PromptLoadingSuccess) result;
+ assertThat(success.promptContent()).isEqualTo(STUB_CONTENT);
+ assertThat(success.promptIdentifier()).isEqualTo(STUB_IDENTIFIER);
+ assertThat(stubPromptPort.loadCallCount).isEqualTo(1);
+ }
+
+ @Test
+ void loadPrompt_shouldDelegateToPromptPort_andReturnFailure() {
+ // Given
+ stubPromptPort.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht vorhanden");
+
+ // When
+ PromptLoadingResult result = useCase.loadPrompt();
+
+ // Then
+ assertThat(result).isInstanceOf(PromptLoadingFailure.class);
+ PromptLoadingFailure failure = (PromptLoadingFailure) result;
+ assertThat(failure.failureReason()).isEqualTo("FILE_NOT_FOUND");
+ }
+
+ // -------------------------------------------------------------------------
+ // savePrompt
+ // -------------------------------------------------------------------------
+
+ @Test
+ void savePrompt_shouldDelegateToPromptPort_andReturnSaved() {
+ // Given
+ stubPromptPort.saveResult = new PromptSaveResult.Saved("/some/path/prompt.txt");
+
+ // When
+ PromptSaveResult result = useCase.savePrompt(STUB_CONTENT);
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
+ assertThat(stubPromptPort.lastSavedContent).isEqualTo(STUB_CONTENT);
+ assertThat(stubPromptPort.saveCallCount).isEqualTo(1);
+ }
+
+ @Test
+ void savePrompt_shouldDelegateToPromptPort_andReturnWriteFailed() {
+ // Given
+ stubPromptPort.saveResult = new PromptSaveResult.WriteFailed("Schreibfehler", null);
+
+ // When
+ PromptSaveResult result = useCase.savePrompt(STUB_CONTENT);
+
+ // Then
+ assertThat(result).isInstanceOf(PromptSaveResult.WriteFailed.class);
+ }
+
+ @Test
+ void savePrompt_shouldThrowNullPointerException_whenContentIsNull() {
+ assertThatThrownBy(() -> useCase.savePrompt(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("content");
+ }
+
+ // -------------------------------------------------------------------------
+ // createDefaultPromptIfMissing
+ // -------------------------------------------------------------------------
+
+ @Test
+ void createDefaultPromptIfMissing_shouldDelegateToResourceCreationPort_andReturnApplied() {
+ // Given
+ CorrectionSuggestion.CreatePromptFile suggestion =
+ new CorrectionSuggestion.CreatePromptFile(
+ "/some/prompt.txt", "Standard anlegen", 60);
+ CorrectionOutcome.Applied applied = new CorrectionOutcome.Applied(
+ suggestion, "Standard-Prompt-Datei wurde angelegt.");
+ stubResourceCreationPort.createPromptFileResult = applied;
+
+ // When
+ CorrectionOutcome result = useCase.createDefaultPromptIfMissing(suggestion);
+
+ // Then
+ assertThat(result).isInstanceOf(CorrectionOutcome.Applied.class);
+ assertThat(stubResourceCreationPort.createPromptFileCallCount).isEqualTo(1);
+ assertThat(stubResourceCreationPort.lastSuggestion).isSameAs(suggestion);
+ }
+
+ @Test
+ void createDefaultPromptIfMissing_shouldThrowNullPointerException_whenSuggestionIsNull() {
+ assertThatThrownBy(() -> useCase.createDefaultPromptIfMissing(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("suggestion");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test-Stubs
+ // -------------------------------------------------------------------------
+
+ private static class StubPromptPort implements PromptPort {
+ PromptLoadingResult loadResult = new PromptLoadingSuccess(
+ new PromptIdentifier("stub.txt"), "Stub-Inhalt");
+ PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/path.txt");
+ int loadCallCount = 0;
+ int saveCallCount = 0;
+ String lastSavedContent = null;
+
+ @Override
+ public PromptLoadingResult loadPrompt() {
+ loadCallCount++;
+ return loadResult;
+ }
+
+ @Override
+ public PromptSaveResult savePrompt(String content) {
+ saveCallCount++;
+ lastSavedContent = content;
+ return saveResult;
+ }
+ }
+
+ private static class StubResourceCreationPort implements ResourceCreationPort {
+ CorrectionOutcome createPromptFileResult = new CorrectionOutcome.Applied(
+ new CorrectionSuggestion.CreatePromptFile("/stub.txt", "Stub", 60),
+ "Angelegt.");
+ int createPromptFileCallCount = 0;
+ CorrectionSuggestion.CreatePromptFile lastSuggestion = null;
+
+ @Override
+ public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
+ return new CorrectionOutcome.NotAttempted(suggestion, "Nicht implementiert im Stub.");
+ }
+
+ @Override
+ public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
+ createPromptFileCallCount++;
+ lastSuggestion = suggestion;
+ return createPromptFileResult;
+ }
+
+ @Override
+ public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
+ return new CorrectionOutcome.NotAttempted(suggestion, "Nicht implementiert im Stub.");
+ }
+ }
+}
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
index 44156dc..f49d9c5 100644
--- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
@@ -21,6 +21,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiPromptEditorPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
@@ -89,6 +90,7 @@ import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordina
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
@@ -827,7 +829,8 @@ public class BootstrapRunner {
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort,
- applicationVersion);
+ applicationVersion,
+ noOpGuiPromptEditorPort());
}
Path configPath = Paths.get(configPathOverride.get());
@@ -852,17 +855,20 @@ public class BootstrapRunner {
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort,
- applicationVersion);
+ applicationVersion,
+ noOpGuiPromptEditorPort());
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
try {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
+ GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort(
+ loadedState.values().promptTemplateFile());
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
- historicalDocumentContextPort, applicationVersion);
+ historicalDocumentContextPort, applicationVersion, promptEditorPort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -883,10 +889,87 @@ public class BootstrapRunner {
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort,
- applicationVersion);
+ applicationVersion,
+ noOpGuiPromptEditorPort());
}
}
+ /**
+ * Erzeugt einen vollständig verdrahteten {@link GuiPromptEditorPort} für den angegebenen
+ * Prompt-Dateipfad.
+ *
+ * Kombiniert {@link FilesystemPromptPortAdapter} und
+ * {@link de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter}
+ * in einem {@link DefaultPromptEditorUseCase}. Der zurückgegebene Port delegiert alle
+ * drei Operationen (Laden, Speichern, Standard-Anlegen) an den Use-Case.
+ *
+ * @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
+ * @return vollständig verdrahteter Port; nie {@code null}
+ */
+ private GuiPromptEditorPort buildGuiPromptEditorPort(String promptFilePath) {
+ FilesystemPromptPortAdapter promptPortAdapter =
+ new FilesystemPromptPortAdapter(Paths.get(promptFilePath));
+ de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort
+ resourceCreationPort =
+ new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter();
+ DefaultPromptEditorUseCase useCase = new DefaultPromptEditorUseCase(
+ promptPortAdapter, resourceCreationPort);
+ return new GuiPromptEditorPort() {
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
+ return useCase.loadPrompt();
+ }
+
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
+ return useCase.savePrompt(content);
+ }
+
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
+ createDefaultPromptIfMissing(
+ de.gecheckt.pdf.umbenenner.application.validation.technicaltest
+ .CorrectionSuggestion.CreatePromptFile suggestion) {
+ return useCase.createDefaultPromptIfMissing(suggestion);
+ }
+ };
+ }
+
+ /**
+ * Gibt einen No-Op-{@link GuiPromptEditorPort} zurück, der alle Operationen als
+ * nicht verfügbar meldet.
+ *
+ * Wird eingesetzt, wenn beim GUI-Start noch keine Konfiguration geladen ist und daher
+ * kein Prompt-Dateipfad bekannt ist.
+ *
+ * @return no-op Port; nie {@code null}
+ */
+ private static GuiPromptEditorPort noOpGuiPromptEditorPort() {
+ return new GuiPromptEditorPort() {
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
+ return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
+ "NO_OP", "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.");
+ }
+
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
+ return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
+ "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.", null);
+ }
+
+ @Override
+ public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
+ createDefaultPromptIfMissing(
+ de.gecheckt.pdf.umbenenner.application.validation.technicaltest
+ .CorrectionSuggestion.CreatePromptFile suggestion) {
+ return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
+ .CorrectionOutcome.NotAttempted(
+ suggestion, "Kein Prompt-Pfad konfiguriert.");
+ }
+ };
+ }
+
/**
* Executes exactly one batch run triggered by the GUI's processing-run tab.
*
+ *
+ */
+class GuiPromptEditorTabSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform muss innerhalb des Timeouts starten");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Gemeinsame Platform – kein Platform.exit().
+ }
+
+ // =========================================================================
+ // Hilfsklassen
+ // =========================================================================
+
+ /** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */
+ private static class SyncPromptEditorPort implements GuiPromptEditorPort {
+ PromptLoadingResult loadResult = new PromptLoadingSuccess(
+ new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt");
+ PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt");
+
+ @Override
+ public PromptLoadingResult loadCurrentPrompt() {
+ return loadResult;
+ }
+
+ @Override
+ public PromptSaveResult save(String content) {
+ return saveResult;
+ }
+
+ @Override
+ public CorrectionOutcome createDefaultPromptIfMissing(
+ CorrectionSuggestion.CreatePromptFile suggestion) {
+ return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt.");
+ }
+ }
+
+ /**
+ * Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs:
+ * threadFactory führt den Runnable inline aus (vor worker.start()),
+ * fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater).
+ * Damit sind alle Operationen aus Testsicht vollständig synchron.
+ */
+ private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) {
+ GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60);
+ // Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op).
+ tab.threadFactory = runnable -> {
+ runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf
+ return new Thread(); // Dummy-Thread; worker.start() beendet sofort
+ };
+ // UI-Updates synchron im selben Thread
+ tab.fxDispatcher = Runnable::run;
+ return tab;
+ }
+
+ // =========================================================================
+ // Tests
+ // =========================================================================
+
+ @Test
+ void tab_shouldBeCreatedWithTitlePrompt() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference
+ *
+ *
- *
*
- *
*
- *
*/
public interface PromptPort {
/**
- * Loads the configured external prompt template.
+ * Lädt das konfigurierte externe Prompt-Template.
*
- *
+ * Bei einem technischen Fehler (Datei nicht gefunden, I/O-Fehler, leerer Inhalt) wird
+ * {@link PromptLoadingFailure} zurückgegeben – keine Exception wird geworfen.
*
+ * @return {@link PromptLoadingResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @see PromptLoadingSuccess
* @see PromptLoadingFailure
*/
PromptLoadingResult loadPrompt();
+
+ /**
+ * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
+ *
+ *
+ */
+public sealed interface PromptSaveResult
+ permits PromptSaveResult.Saved,
+ PromptSaveResult.WriteFailed,
+ PromptSaveResult.TargetDirectoryMissing,
+ PromptSaveResult.AtomicMoveFailed {
+
+ /**
+ * Die Prompt-Datei wurde erfolgreich gespeichert.
+ *
+ * @param absolutePath absoluter Pfad der gespeicherten Datei; nie {@code null}
+ */
+ record Saved(String absolutePath) implements PromptSaveResult {
+
+ /**
+ * Erstellt ein Saved-Ergebnis.
+ *
+ * @param absolutePath absoluter Pfad der gespeicherten Datei; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code absolutePath} null ist
+ */
+ public Saved {
+ java.util.Objects.requireNonNull(absolutePath, "absolutePath must not be null");
+ }
+ }
+
+ /**
+ * Das Schreiben der temporären Datei ist fehlgeschlagen.
+ *
+ * @param message Fehlerbeschreibung; nie {@code null}
+ * @param cause auslösende Ausnahme; kann {@code null} sein
+ */
+ record WriteFailed(String message, Throwable cause) implements PromptSaveResult {
+
+ /**
+ * Erstellt ein WriteFailed-Ergebnis.
+ *
+ * @param message Fehlerbeschreibung; darf nicht {@code null} sein
+ * @param cause auslösende Ausnahme; kann {@code null} sein
+ * @throws NullPointerException wenn {@code message} null ist
+ */
+ public WriteFailed {
+ java.util.Objects.requireNonNull(message, "message must not be null");
+ }
+ }
+
+ /**
+ * Der konfigurierte Zielordner existiert nicht.
+ *
+ * @param message Beschreibung des fehlenden Ordners; nie {@code null}
+ */
+ record TargetDirectoryMissing(String message) implements PromptSaveResult {
+
+ /**
+ * Erstellt ein TargetDirectoryMissing-Ergebnis.
+ *
+ * @param message Beschreibung des fehlenden Ordners; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code message} null ist
+ */
+ public TargetDirectoryMissing {
+ java.util.Objects.requireNonNull(message, "message must not be null");
+ }
+ }
+
+ /**
+ * Das atomare Verschieben der temporären Datei zur Zieldatei ist fehlgeschlagen.
+ * Es wird kein stiller Fallback auf nicht-atomares Schreiben durchgeführt.
+ *
+ * @param message Fehlerbeschreibung; nie {@code null}
+ */
+ record AtomicMoveFailed(String message) implements PromptSaveResult {
+
+ /**
+ * Erstellt ein AtomicMoveFailed-Ergebnis.
+ *
+ * @param message Fehlerbeschreibung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code message} null ist
+ */
+ public AtomicMoveFailed {
+ java.util.Objects.requireNonNull(message, "message must not be null");
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCase.java
new file mode 100644
index 0000000..e4f7130
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCase.java
@@ -0,0 +1,101 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import java.util.Objects;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
+
+/**
+ * Use-Case zur Anzeige und Bearbeitung des KI-Prompt-Templates über die GUI.
+ *
+ *
+ *