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 @@ mockito-junit-jupiter test + + org.assertj + assertj-core + test + diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java new file mode 100644 index 0000000..0a2ce6c --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiRequestComposer.java @@ -0,0 +1,142 @@ +package de.gecheckt.pdf.umbenenner.application.service; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; +import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; + +/** + * Composes deterministic AI request representations from prompt and document text. + *

+ * 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: + *

    + *
  1. Prompt content
  2. + *
  3. Separator: newline
  4. + *
  5. Prompt identifier (for reference/traceability)
  6. + *
  7. Separator: newline
  8. + *
  9. Document text section marker
  10. + *
  11. Document text content
  12. + *
+ *

+ * This fixed order ensures that: + *

+ * + * @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. + *

+ * 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 @@ *

  • {@link de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator} * — Per-document idempotency, status/counter mapping and consistent * two-level persistence
  • + *
  • {@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer} + * — Deterministic composition of AI request representations from prompt and document text
  • * * *

    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"); + } +}