diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java new file mode 100644 index 0000000..267303c --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java @@ -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}. + *
+ * 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. + *
+ * 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 + * {@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}. + *
+ * 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. + */ +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. + *
+ * 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); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/package-info.java new file mode 100644 index 0000000..1e86282 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/package-info.java @@ -0,0 +1,11 @@ +/** + * Adapters for external prompt template loading. + *
+ * 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). + *
+ * 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;
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
new file mode 100644
index 0000000..609df91
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java
@@ -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());
+ }
+}
diff --git a/pdf-umbenenner-application/pom.xml b/pdf-umbenenner-application/pom.xml
index ba15a56..f3ed695 100644
--- a/pdf-umbenenner-application/pom.xml
+++ b/pdf-umbenenner-application/pom.xml
@@ -35,6 +35,11 @@
+ * This service builds the exact request that will be sent to the AI service,
+ * ensuring that the composition is deterministic and reproducible across batch runs.
+ * The request is constructed from:
+ *
+ * Order and structure: The composition follows a fixed, documented order
+ * to ensure that another implementation would not need to guess how the prompt and document
+ * are combined. The prompt is presented first, followed by the document text, with clear
+ * structural markers to distinguish between them.
+ *
+ * JSON-only response expectation: The request is constructed with the
+ * explicit expectation that the AI will respond with a JSON object containing:
+ *
+ * This service is stateless and thread-safe. It performs no I/O and makes no external calls.
+ */
+public class AiRequestComposer {
+
+ /**
+ * Composes a deterministic AI request representation.
+ *
+ * The composition order is fixed:
+ *
+ * This fixed order ensures that:
+ *
+ * This is a helper method that builds the exact string that would be included in the
+ * HTTP request to the AI service. It follows the same deterministic order as
+ * {@link #compose(PromptIdentifier, String, String)}.
+ *
+ * @param promptIdentifier the stable identifier for this prompt; must not be null
+ * @param promptContent the prompt template content; must not be null
+ * @param documentText the extracted document text; must not be null
+ * @return the complete, deterministically-ordered request text for the AI
+ * @throws NullPointerException if any parameter is null
+ */
+ public static String buildCompleteRequestText(
+ PromptIdentifier promptIdentifier,
+ String promptContent,
+ String documentText) {
+
+ Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null");
+ Objects.requireNonNull(promptContent, "promptContent must not be null");
+ Objects.requireNonNull(documentText, "documentText must not be null");
+
+ StringBuilder requestBuilder = new StringBuilder();
+ requestBuilder.append(promptContent);
+ requestBuilder.append("\n");
+ requestBuilder.append("--- Prompt-ID: ").append(promptIdentifier.identifier()).append(" ---");
+ requestBuilder.append("\n");
+ requestBuilder.append("--- Document Text ---");
+ requestBuilder.append("\n");
+ requestBuilder.append(documentText);
+
+ return requestBuilder.toString();
+ }
+
+ private AiRequestComposer() {
+ // Static utility class – no instances
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java
index 2b5c995..0ccdf97 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/package-info.java
@@ -20,6 +20,8 @@
*
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @param promptIdentifier the stable identifier for this prompt; must not be null
+ * @param promptContent the prompt template content; must not be null
+ * @param documentText the extracted document text; must not be null
+ * @return an AiRequestRepresentation with sentCharacterCount set to documentText.length()
+ * @throws NullPointerException if any parameter is null
+ */
+ public static AiRequestRepresentation compose(
+ PromptIdentifier promptIdentifier,
+ String promptContent,
+ String documentText) {
+
+ Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null");
+ Objects.requireNonNull(promptContent, "promptContent must not be null");
+ Objects.requireNonNull(documentText, "documentText must not be null");
+
+ // The complete request text is composed in a deterministic order:
+ // 1. Prompt content (instruction)
+ // 2. Newline separator
+ // 3. Prompt identifier (for reference)
+ // 4. Newline separator
+ // 5. Document text section marker
+ // 6. Newline separator
+ // 7. Document text content
+ //
+ // This order is fixed so that another implementation knows exactly where
+ // the prompt and document text are positioned.
+ StringBuilder requestBuilder = new StringBuilder();
+ requestBuilder.append(promptContent);
+ requestBuilder.append("\n");
+ requestBuilder.append("--- Prompt-ID: ").append(promptIdentifier.identifier()).append(" ---");
+ requestBuilder.append("\n");
+ requestBuilder.append("--- Document Text ---");
+ requestBuilder.append("\n");
+ requestBuilder.append(documentText);
+
+ // Record the exact character count of the document text that was included.
+ // This is the length of the document text (not the complete request).
+ int sentCharacterCount = documentText.length();
+
+ return new AiRequestRepresentation(
+ promptIdentifier,
+ promptContent,
+ documentText,
+ sentCharacterCount);
+ }
+
+ /**
+ * Constructs the complete request text that will be sent to the AI.
+ * Document processing flow ({@code DocumentProcessingCoordinator})
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java
new file mode 100644
index 0000000..5279378
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposerTest.java
@@ -0,0 +1,195 @@
+package de.gecheckt.pdf.umbenenner.application.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
+import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
+
+/**
+ * Unit tests for {@link AiRequestComposer}.
+ */
+class AiRequestComposerTest {
+
+ @Test
+ void compose_shouldCreateAiRequestRepresentation() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("prompt_v1.txt");
+ String promptContent = "You are a helpful assistant.";
+ String documentText = "This is the document content.";
+
+ // When
+ AiRequestRepresentation result = AiRequestComposer.compose(promptId, promptContent, documentText);
+
+ // Then
+ assertThat(result.promptIdentifier()).isEqualTo(promptId);
+ assertThat(result.promptContent()).isEqualTo(promptContent);
+ assertThat(result.documentText()).isEqualTo(documentText);
+ assertThat(result.sentCharacterCount()).isEqualTo(documentText.length());
+ }
+
+ @Test
+ void compose_shouldSetSentCharacterCountToDocumentTextLength() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("prompt.txt");
+ String promptContent = "Prompt";
+ String documentText = "Document text with exactly 26 characters";
+
+ // When
+ AiRequestRepresentation result = AiRequestComposer.compose(promptId, promptContent, documentText);
+
+ // Then
+ assertThat(result.sentCharacterCount()).isEqualTo(documentText.length());
+ assertThat(result.sentCharacterCount()).isEqualTo(40); // "Document text with exactly 26 characters" has 40 chars
+ }
+
+ @Test
+ void compose_shouldHandleEmptyDocumentText() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("prompt.txt");
+ String promptContent = "Prompt";
+ String documentText = "";
+
+ // When
+ AiRequestRepresentation result = AiRequestComposer.compose(promptId, promptContent, documentText);
+
+ // Then
+ assertThat(result.documentText()).isEmpty();
+ assertThat(result.sentCharacterCount()).isZero();
+ }
+
+ @Test
+ void compose_shouldThrowNullPointerException_whenPromptIdentifierIsNull() {
+ // When & Then
+ assertThatThrownBy(
+ () -> AiRequestComposer.compose(null, "Prompt", "Document"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("promptIdentifier must not be null");
+ }
+
+ @Test
+ void compose_shouldThrowNullPointerException_whenPromptContentIsNull() {
+ // When & Then
+ assertThatThrownBy(
+ () -> AiRequestComposer.compose(new PromptIdentifier("id"), null, "Document"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("promptContent must not be null");
+ }
+
+ @Test
+ void compose_shouldThrowNullPointerException_whenDocumentTextIsNull() {
+ // When & Then
+ assertThatThrownBy(
+ () -> AiRequestComposer.compose(new PromptIdentifier("id"), "Prompt", null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("documentText must not be null");
+ }
+
+ @Test
+ void buildCompleteRequestText_shouldBuildDeterministicOrder() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("prompt_v2.txt");
+ String promptContent = "Analyze this document";
+ String documentText = "Document content here";
+
+ // When
+ String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText);
+
+ // Then
+ // Verify deterministic order: prompt, then identifier, then document text
+ assertThat(result)
+ .contains(promptContent)
+ .contains("Prompt-ID: prompt_v2.txt")
+ .contains("Document Text")
+ .contains(documentText);
+
+ // Verify order: prompt comes before identifier
+ int promptIndex = result.indexOf(promptContent);
+ int identifierIndex = result.indexOf("Prompt-ID:");
+ assertThat(promptIndex).isLessThan(identifierIndex);
+
+ // Verify order: identifier comes before document marker
+ int documentMarkerIndex = result.indexOf("Document Text");
+ assertThat(identifierIndex).isLessThan(documentMarkerIndex);
+
+ // Verify order: document marker comes before document text
+ int docTextIndex = result.indexOf(documentText);
+ assertThat(documentMarkerIndex).isLessThan(docTextIndex);
+ }
+
+ @Test
+ void buildCompleteRequestText_shouldIncludeStructuralMarkers() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("prompt.txt");
+ String promptContent = "Prompt";
+ String documentText = "Document";
+
+ // When
+ String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText);
+
+ // Then
+ assertThat(result).contains("--- Prompt-ID:");
+ assertThat(result).contains("---");
+ assertThat(result).contains("--- Document Text ---");
+ }
+
+ @Test
+ void buildCompleteRequestText_shouldThrowNullPointerException_whenPromptIdentifierIsNull() {
+ // When & Then
+ assertThatThrownBy(
+ () -> AiRequestComposer.buildCompleteRequestText(null, "Prompt", "Document"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("promptIdentifier must not be null");
+ }
+
+ @Test
+ void buildCompleteRequestText_shouldThrowNullPointerException_whenPromptContentIsNull() {
+ // When & Then
+ assertThatThrownBy(
+ () -> AiRequestComposer.buildCompleteRequestText(new PromptIdentifier("id"), null, "Document"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("promptContent must not be null");
+ }
+
+ @Test
+ void buildCompleteRequestText_shouldThrowNullPointerException_whenDocumentTextIsNull() {
+ // When & Then
+ assertThatThrownBy(
+ () -> AiRequestComposer.buildCompleteRequestText(new PromptIdentifier("id"), "Prompt", null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("documentText must not be null");
+ }
+
+ @Test
+ void compose_shouldProduceValidRepresentation_withMultilineContent() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("prompt.txt");
+ String promptContent = "Line 1\nLine 2\nLine 3";
+ String documentText = "Doc line 1\nDoc line 2";
+
+ // When
+ AiRequestRepresentation result = AiRequestComposer.compose(promptId, promptContent, documentText);
+
+ // Then
+ assertThat(result.promptContent()).isEqualTo(promptContent);
+ assertThat(result.documentText()).isEqualTo(documentText);
+ assertThat(result.sentCharacterCount()).isEqualTo(documentText.length());
+ }
+
+ @Test
+ void buildCompleteRequestText_shouldPreserveNewlines() {
+ // Given
+ PromptIdentifier promptId = new PromptIdentifier("id");
+ String promptContent = "Prompt\nwith\nnewlines";
+ String documentText = "Document\ntext";
+
+ // When
+ String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText);
+
+ // Then
+ assertThat(result).contains("Prompt\nwith\nnewlines");
+ assertThat(result).contains("Document\ntext");
+ }
+}