#71: Prompt-Editor-Tab in der GUI implementieren

Neuer Tab „Prompt" in der GUI-Hauptansicht ermöglicht das Lesen, Bearbeiten
und atomare Speichern der konfigurierten KI-Prompt-Datei ohne externen Editor.

Änderungen:
- PromptSaveResult: neue sealed interface mit Saved, WriteFailed, TargetDirectoryMissing,
  AtomicMoveFailed als strukturierte Ergebnistypen für savePrompt()
- PromptPort: um savePrompt(String) erweitert (nicht mehr funktional – Teststubs angepasst)
- FilesystemPromptPortAdapter: savePrompt() mit Temp-Datei im selben Verzeichnis + ATOMIC_MOVE,
  kein stiller Fallback bei AtomicMoveNotSupportedException
- DefaultPromptEditorUseCase: Use-Case-Klasse mit loadPrompt(), savePrompt(),
  createDefaultPromptIfMissing() als Delegation an PromptPort und ResourceCreationPort
- GuiPromptEditorPort: GUI-internes Bridge-Interface (kein hexagonaler Port)
- GuiPromptEditorTab: JavaFX-Tab mit TextArea, Dirty-State-Tracking, Speichern/Reset/Anlegen,
  injizierbare threadFactory + fxDispatcher für Testbarkeit
- GuiStartupContext: um promptEditorPort erweitert; alle Backward-Compat-Konstruktoren
  und blank() mit noOpPromptEditorPort() versorgt
- GuiConfigurationEditorWorkspace: promptEditorTab integriert, Tab-Wechsel-Schutz erweitert
- BootstrapRunner: buildGuiPromptEditorPort() verdrahtet FilesystemPromptPortAdapter +
  DefaultPromptEditorUseCase; noOpGuiPromptEditorPort() für Blank-Start-Fälle
- Tests: DefaultPromptEditorUseCaseTest, FilesystemPromptPortAdapterTest (savePrompt),
  GuiPromptEditorTabSmokeTest (headless Monocle), GuiAdapterSmokeTest auf 3 Tabs aktualisiert
- docs/betrieb.md: Prompt-Tab dokumentiert, Pfad-Auflösungstabelle ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 13:13:47 +02:00
parent 4f5ce4c750
commit 5d5dee0bbf
16 changed files with 1638 additions and 71 deletions
@@ -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.
* <p>
* 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.
* <p>
* <strong>Design principles:</strong>
* <strong>Designprinzipien:</strong>
* <ul>
* <li>Prompt is not embedded in code; it is loaded from an external source</li>
* <li>Each prompt receives a stable identifier for traceability across batch runs</li>
* <li>Results are returned as structured types ({@link PromptLoadingResult}),
* never as exceptions</li>
* <li>Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.</li>
* <li>Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.</li>
* <li>Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.</li>
* <li>Der Pfad zur Prompt-Datei ist Implementierungsdetail des Adapters er erscheint nicht
* in der Port-Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen).</li>
* </ul>
* <p>
* <strong>Adapter responsibilities:</strong>
* <strong>Adapter-Verantwortung:</strong>
* <ul>
* <li>Locate and read the prompt file/resource from the configured source</li>
* <li>Derive a stable prompt identifier (e.g., filename, semantic version, content hash)</li>
* <li>Validate that the loaded content is not empty or otherwise invalid</li>
* <li>Return either success or a classified failure</li>
* <li>Encapsulate all file I/O, resource loading, and configuration details</li>
* <li>Prompt-Datei lokalisieren und lesen.</li>
* <li>Stabilen Identifikator ableiten (z. B. Dateiname).</li>
* <li>Leere oder technisch unbrauchbare Prompts ablehnen.</li>
* <li>Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.</li>
* <li>Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.</li>
* </ul>
* <p>
* <strong>Non-goals of this port:</strong>
* <strong>Nicht-Ziele dieses Ports:</strong>
* <ul>
* <li>Prompt parsing or templating logic</li>
* <li>Combining prompt with document text (Application layer handles this)</li>
* <li>Template variable substitution</li>
* <li>Validation of prompt content against domain rules</li>
* <li>Prompt-Parsing oder Template-Verarbeitung</li>
* <li>Kombination von Prompt und Dokumenttext (Application-Schicht)</li>
* <li>Validierung des Prompt-Inhalts gegen Domänenregeln</li>
* </ul>
*/
public interface PromptPort {
/**
* Loads the configured external prompt template.
* Lädt das konfigurierte externe Prompt-Template.
* <p>
* 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.
* <p>
* 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:
* <ul>
* <li>Success: prompt content and identifier loaded successfully</li>
* <li>Failure: prompt could not be loaded or is invalid</li>
* </ul>
* 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.
* <p>
* Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist
* <em>nicht</em> Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen
* im Port-Vertrag).
* <p>
* Die Implementierung schreibt zunächst in eine temporäre Datei <em>im selben Verzeichnis</em>
* wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}.
* Bei einem Fehler beim atomaren Verschieben wird <strong>kein stiller Fallback</strong>
* auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird
* {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben.
* <p>
* 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);
}
@@ -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)}.
* <p>
* Mögliche Ergebnisse:
* <ul>
* <li>{@link Saved} das Speichern war erfolgreich.</li>
* <li>{@link WriteFailed} ein technischer Fehler beim Schreiben ist aufgetreten.</li>
* <li>{@link TargetDirectoryMissing} der konfigurierte Zielordner existiert nicht.</li>
* <li>{@link AtomicMoveFailed} das atomare Verschieben der temporären Datei ist
* fehlgeschlagen; kein stiller Fallback.</li>
* </ul>
*/
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");
}
}
}
@@ -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.
* <p>
* 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.
* <p>
* <strong>Verantwortung:</strong>
* <ul>
* <li>Aktuellen Prompt-Inhalt laden und als strukturiertes Ergebnis zurückgeben.</li>
* <li>Bearbeiteten Inhalt atomar in die konfigurierte Prompt-Datei speichern.</li>
* <li>Anlegen einer Standard-Prompt-Datei delegieren, wenn keine Datei vorhanden ist.</li>
* </ul>
* <p>
* <strong>Abgrenzung:</strong> 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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);
}
}