M5 AP-002 Externen Prompt geladen und deterministische KI-Anfrage
aufgebaut
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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:
|
||||
* <ul>
|
||||
* <li>The loaded prompt content (from the external prompt file)</li>
|
||||
* <li>The stable prompt identifier (derived from the prompt source)</li>
|
||||
* <li>The extracted document text (already limited to max characters if needed)</li>
|
||||
* <li>The exact character count that was sent (for traceability)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Order and structure:</strong> 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.
|
||||
* <p>
|
||||
* <strong>JSON-only response expectation:</strong> The request is constructed with the
|
||||
* explicit expectation that the AI will respond with a JSON object containing:
|
||||
* <ul>
|
||||
* <li>{@code title} — mandatory, max 20 characters (base title)</li>
|
||||
* <li>{@code reasoning} — mandatory, the AI's explanation</li>
|
||||
* <li>{@code date} — optional, should be in YYYY-MM-DD format if present</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* The composition order is fixed:
|
||||
* <ol>
|
||||
* <li>Prompt content</li>
|
||||
* <li>Separator: newline</li>
|
||||
* <li>Prompt identifier (for reference/traceability)</li>
|
||||
* <li>Separator: newline</li>
|
||||
* <li>Document text section marker</li>
|
||||
* <li>Document text content</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* This fixed order ensures that:
|
||||
* <ul>
|
||||
* <li>The prompt guides the AI</li>
|
||||
* <li>The document text is clearly separated and identified</li>
|
||||
* <li>Another implementation knows exactly where each part begins</li>
|
||||
* <li>The composition is deterministic and reproducible</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
* <p>
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator}
|
||||
* — Per-document idempotency, status/counter mapping and consistent
|
||||
* two-level persistence</li>
|
||||
* <li>{@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer}
|
||||
* — Deterministic composition of AI request representations from prompt and document text</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Document processing flow ({@code DocumentProcessingCoordinator})</h2>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user