1
0

M5 AP-002 Externen Prompt geladen und deterministische KI-Anfrage

aufgebaut
This commit is contained in:
2026-04-07 00:02:20 +02:00
parent c15fb6b18d
commit cd5b6253df
7 changed files with 654 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
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.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
/**
* Filesystem-based implementation of {@link PromptPort}.
* <p>
* 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.
* <p>
* <strong>Identifier derivation:</strong>
* 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
* {@code "prompt_de_v2.txt"}.
* <p>
* <strong>Content validation:</strong>
* 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}.
* <p>
* <strong>Error handling:</strong>
* All technical failures (file not found, I/O errors, permission issues) are caught
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
*/
public class FilesystemPromptPortAdapter implements PromptPort {
private static final Logger LOG = LogManager.getLogger(FilesystemPromptPortAdapter.class);
private final Path promptFilePath;
/**
* Creates the adapter with the configured prompt file path.
*
* @param promptFilePath the path to the prompt template file; must not be null
* @throws NullPointerException if promptFilePath is null
*/
public FilesystemPromptPortAdapter(Path promptFilePath) {
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
}
@Override
public PromptLoadingResult loadPrompt() {
try {
if (!Files.exists(promptFilePath)) {
return new PromptLoadingFailure(
"FILE_NOT_FOUND",
"Prompt file not found at: " + promptFilePath);
}
String content = Files.readString(promptFilePath, StandardCharsets.UTF_8);
String trimmedContent = content.trim();
if (trimmedContent.isEmpty()) {
return new PromptLoadingFailure(
"EMPTY_CONTENT",
"Prompt file is empty or contains only whitespace: " + promptFilePath);
}
PromptIdentifier identifier = deriveIdentifier();
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
return new PromptLoadingSuccess(identifier, trimmedContent);
} catch (IOException e) {
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
return new PromptLoadingFailure(
"IO_ERROR",
"Failed to read prompt file: " + e.getMessage());
}
}
/**
* Derives a stable prompt identifier from the filename.
* <p>
* The identifier is simply the filename (without the directory path).
* This ensures that the same prompt file always receives the same identifier.
*
* @return a stable PromptIdentifier based on the filename
*/
private PromptIdentifier deriveIdentifier() {
String filename = promptFilePath.getFileName().toString();
return new PromptIdentifier(filename);
}
}

View File

@@ -0,0 +1,11 @@
/**
* Adapters for external prompt template loading.
* <p>
* This package provides concrete implementations of the {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort}
* interface. These adapters handle all technical details of locating, loading, and validating prompt templates
* from external sources (typically filesystem files).
* <p>
* Prompt files are never embedded in code. They are loaded at runtime, assigned stable identifiers for
* traceability, and validated to ensure they are not empty or technically unusable.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.prompt;

View File

@@ -0,0 +1,202 @@
package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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;
/**
* Unit tests for {@link FilesystemPromptPortAdapter}.
*/
class FilesystemPromptPortAdapterTest {
private FilesystemPromptPortAdapter adapter;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
// Adapter will be created with a specific prompt file path in each test
}
@Test
void loadPrompt_shouldReturnSuccess_whenPromptFileExists() throws IOException {
// Given
String promptContent = "You are a helpful AI assistant that renames documents.";
Path promptFile = tempDir.resolve("prompt.txt");
Files.writeString(promptFile, promptContent, StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) result;
assertThat(success.promptContent()).isEqualTo(promptContent);
assertThat(success.promptIdentifier().identifier()).isEqualTo("prompt.txt");
}
@Test
void loadPrompt_shouldTrimWhitespace_whenPromptContainsLeadingTrailingWhitespace() throws IOException {
// Given
String promptContent = " \n Helpful AI assistant \n ";
Path promptFile = tempDir.resolve("prompt_whitespace.txt");
Files.writeString(promptFile, promptContent, StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) result;
assertThat(success.promptContent()).isEqualTo("Helpful AI assistant");
}
@Test
void loadPrompt_shouldDeriveIdentifierFromFilename() throws IOException {
// Given
String promptContent = "Test prompt";
Path promptFile = tempDir.resolve("prompt_v2_de.txt");
Files.writeString(promptFile, promptContent, StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) result;
assertThat(success.promptIdentifier().identifier()).isEqualTo("prompt_v2_de.txt");
}
@Test
void loadPrompt_shouldReturnFailure_whenFileDoesNotExist() {
// Given
Path nonExistentFile = tempDir.resolve("nonexistent_prompt.txt");
adapter = new FilesystemPromptPortAdapter(nonExistentFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingFailure.class);
PromptLoadingFailure failure = (PromptLoadingFailure) result;
assertThat(failure.failureReason()).isEqualTo("FILE_NOT_FOUND");
assertThat(failure.failureMessage()).contains("nonexistent_prompt.txt");
}
@Test
void loadPrompt_shouldReturnFailure_whenPromptIsEmpty() throws IOException {
// Given
Path promptFile = tempDir.resolve("empty_prompt.txt");
Files.writeString(promptFile, "", StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingFailure.class);
PromptLoadingFailure failure = (PromptLoadingFailure) result;
assertThat(failure.failureReason()).isEqualTo("EMPTY_CONTENT");
assertThat(failure.failureMessage()).contains("empty or contains only whitespace");
}
@Test
void loadPrompt_shouldReturnFailure_whenPromptContainsOnlyWhitespace() throws IOException {
// Given
Path promptFile = tempDir.resolve("whitespace_only_prompt.txt");
Files.writeString(promptFile, " \n\n \t \n", StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingFailure.class);
PromptLoadingFailure failure = (PromptLoadingFailure) result;
assertThat(failure.failureReason()).isEqualTo("EMPTY_CONTENT");
assertThat(failure.failureMessage()).contains("empty or contains only whitespace");
}
@Test
void loadPrompt_shouldReturnFailure_withIOError_whenFileCannotBeRead() throws IOException {
// Given
Path promptFile = tempDir.resolve("readable_prompt.txt");
Files.writeString(promptFile, "Test prompt content", StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// Delete the file before calling loadPrompt to simulate a file disappearing
Files.delete(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingFailure.class);
PromptLoadingFailure failure = (PromptLoadingFailure) result;
assertThat(failure.failureReason()).isEqualTo("FILE_NOT_FOUND");
}
@Test
void loadPrompt_shouldThrowNullPointerException_whenPromptFilePathIsNull() {
// When & Then
assertThatThrownBy(() -> new FilesystemPromptPortAdapter(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("promptFilePath must not be null");
}
@Test
void loadPrompt_shouldPreserveMultilineContent() throws IOException {
// Given
String promptContent = "Line 1\nLine 2\nLine 3";
Path promptFile = tempDir.resolve("multiline_prompt.txt");
Files.writeString(promptFile, promptContent, StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result = adapter.loadPrompt();
// Then
assertThat(result).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) result;
assertThat(success.promptContent()).isEqualTo(promptContent);
}
@Test
void loadPrompt_shouldBeDeterministic_whenCalledMultipleTimes() throws IOException {
// Given
String promptContent = "Deterministic prompt content";
Path promptFile = tempDir.resolve("stable_prompt.txt");
Files.writeString(promptFile, promptContent, StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptLoadingResult result1 = adapter.loadPrompt();
PromptLoadingResult result2 = adapter.loadPrompt();
// Then
assertThat(result1).isInstanceOf(PromptLoadingSuccess.class);
assertThat(result2).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success1 = (PromptLoadingSuccess) result1;
PromptLoadingSuccess success2 = (PromptLoadingSuccess) result2;
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
}
}