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 index 0a2ce6c..edf11fa 100644 --- 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 @@ -15,16 +15,25 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; *
- * 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. + * Composition order: The request follows a fixed, documented order: + *
- * JSON-only response expectation: The request is constructed with the - * explicit expectation that the AI will respond with a JSON object containing: + * This fixed order ensures reproducibility and allows another implementation to + * understand the request structure without guessing. + *
+ * JSON-only response expectation: The actual composed request includes + * an explicit specification that the AI must respond with a JSON object containing + * exactly these fields: *
- * The composition order is fixed: + * The composition order is fixed and documented to ensure determinism and + * to make the structure obvious to any reader: *
* 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)}. + * {@link #compose(PromptIdentifier, String, String)}, including the explicit + * JSON-only response format specification. * * @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 + * @return the complete, deterministically-ordered request text for the AI (includes JSON format spec) * @throws NullPointerException if any parameter is null */ public static String buildCompleteRequestText( @@ -132,10 +151,37 @@ public class AiRequestComposer { requestBuilder.append("--- Document Text ---"); requestBuilder.append("\n"); requestBuilder.append(documentText); + requestBuilder.append("\n"); + appendJsonResponseFormat(requestBuilder); return requestBuilder.toString(); } + /** + * Appends the explicit JSON-only response format specification to the request. + *
+ * This ensures that the AI knows it must respond with a JSON object containing + * exactly these fields: {@code title}, {@code reasoning}, and optionally {@code date}. + *
+ * This specification is part of the deterministic composition and is included in + * the actual request text sent to the AI service. + * + * @param requestBuilder the StringBuilder to append the format specification to + */ + private static void appendJsonResponseFormat(StringBuilder requestBuilder) { + requestBuilder.append("--- Response Format (JSON-only) ---"); + requestBuilder.append("\n"); + requestBuilder.append("Respond with a JSON object containing exactly:"); + requestBuilder.append("\n"); + requestBuilder.append(" \"title\": string (mandatory, max 20 characters, base title only)"); + requestBuilder.append("\n"); + requestBuilder.append(" \"reasoning\": string (mandatory, explanation of the decision)"); + requestBuilder.append("\n"); + requestBuilder.append(" \"date\": string (optional, YYYY-MM-DD format if provided)"); + requestBuilder.append("\n"); + requestBuilder.append("No text outside the JSON object."); + } + private AiRequestComposer() { // Static utility class – no instances } 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 index 5279378..cdcbe89 100644 --- 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 @@ -192,4 +192,70 @@ class AiRequestComposerTest { assertThat(result).contains("Prompt\nwith\nnewlines"); assertThat(result).contains("Document\ntext"); } + + @Test + void buildCompleteRequestText_shouldIncludeJsonResponseFormat() { + // Given + PromptIdentifier promptId = new PromptIdentifier("prompt.txt"); + String promptContent = "Analyze the document."; + String documentText = "Sample document content"; + + // When + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + + // Then + // Verify that the actual composed request includes JSON response format specification + assertThat(result).contains("Response Format (JSON-only)"); + assertThat(result).contains("\"title\""); + assertThat(result).contains("\"reasoning\""); + assertThat(result).contains("\"date\""); + assertThat(result).contains("mandatory"); + assertThat(result).contains("optional"); + assertThat(result).contains("No text outside the JSON object"); + } + + @Test + void buildCompleteRequestText_shouldHaveDeterministicJsonFormatOrder() { + // Given + PromptIdentifier promptId = new PromptIdentifier("prompt.txt"); + String promptContent = "Process this."; + String documentText = "Document to process"; + + // When + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + + // Then + // Verify deterministic order: document text comes before JSON response format + int documentTextIndex = result.indexOf(documentText); + int jsonFormatIndex = result.indexOf("Response Format (JSON-only)"); + assertThat(documentTextIndex).isLessThan(jsonFormatIndex); + + // Verify JSON field order: title before reasoning, reasoning before date + int titleIndex = result.indexOf("\"title\""); + int reasoningIndex = result.indexOf("\"reasoning\""); + int dateIndex = result.indexOf("\"date\""); + assertThat(titleIndex).isLessThan(reasoningIndex); + assertThat(reasoningIndex).isLessThan(dateIndex); + } + + @Test + void buildCompleteRequestText_shouldProduceCompleteRequest_withAllSections() { + // Given + PromptIdentifier promptId = new PromptIdentifier("prompt_v3.txt"); + String promptContent = "Classify the document."; + String documentText = "Content to classify"; + + // When + String result = AiRequestComposer.buildCompleteRequestText(promptId, promptContent, documentText); + + // Then + // Verify all sections are present in the actual composed request + assertThat(result) + .contains("Classify the document.") // prompt + .contains("--- Prompt-ID: prompt_v3.txt ---") // identifier + .contains("--- Document Text ---") // document marker + .contains("Content to classify") // document text + .contains("--- Response Format (JSON-only) ---") // JSON format marker + .contains("Respond with a JSON object containing exactly:"); // JSON instruction + } }