From 0f0794787982662a5611b726313ee8a1c823930a Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Wed, 22 Apr 2026 13:06:35 +0200 Subject: [PATCH] Fix OpenAI-Adapter: extrahiert choices[0].message.content zweistufig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die OpenAI Chat Completions API liefert den eigentlichen KI-Inhalt als escaped JSON-String in choices[0].message.content, nicht als direktes JSON-Objekt. Der Adapter gab bisher den gesamten Envelope zurück, was dazu führte, dass AiResponseParser das Pflichtfeld 'title' nicht fand. Neues Verhalten: extractContentFromResponse() parst zunächst den äußeren Envelope und gibt choices[0].message.content als AiRawResponse-Inhalt weiter – analog zum AnthropicClaudeHttpAdapter. Bei fehlendem Inhalt (leer, kein choices-Array) oder unparseablem Envelope wird eine technische Failure (NO_CHOICE_CONTENT bzw. UNPARSEABLE_JSON) zurückgegeben. Tests aktualisiert und drei neue Tests für den zweistufigen Parse-Pfad sowie für Fehlerfälle ergänzt. Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/out/ai/OpenAiHttpAdapter.java | 60 +++++++++++- .../adapter/out/ai/OpenAiHttpAdapterTest.java | 91 +++++++++++++++---- 2 files changed, 131 insertions(+), 20 deletions(-) diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java index 7f27c5b..9e2119b 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java @@ -9,6 +9,8 @@ import java.util.Objects; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; @@ -61,9 +63,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; *

* Response handling: *

*

@@ -184,7 +191,7 @@ public class OpenAiHttpAdapter implements AiInvocationPort { HttpResponse response = executeRequest(httpRequest); if (response.statusCode() == 200) { - return new AiInvocationSuccess(request, new AiRawResponse(response.body())); + return extractContentFromResponse(request, response.body()); } else { String reason = "HTTP_" + response.statusCode(); String message = "AI service returned status " + response.statusCode(); @@ -220,6 +227,51 @@ public class OpenAiHttpAdapter implements AiInvocationPort { } } + /** + * Extracts the NamingProposal content from a successful (HTTP 200) OpenAI response. + *

+ * The OpenAI Chat Completions response wraps the actual AI content inside an envelope: + * {@code choices[0].message.content}. This content string is the NamingProposal JSON + * that the Application layer expects. This method performs the two-step extraction: + * first parses the outer envelope, then returns the inner content string. + *

+ * If the envelope JSON cannot be parsed, or if {@code choices} is absent or empty, + * or if {@code message.content} is absent or blank, a technical failure is returned. + * + * @param request the original request (carried through to the result) + * @param responseBody the raw HTTP response body (the OpenAI envelope) + * @return success with the extracted content string, or a technical failure + */ + private AiInvocationResult extractContentFromResponse(AiRequestRepresentation request, String responseBody) { + try { + JSONObject json = new JSONObject(responseBody); + JSONArray choices = json.optJSONArray("choices"); + if (choices == null || choices.isEmpty()) { + LOG.warn("OpenAI response contained no choices"); + return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT", + "OpenAI response contained no choices"); + } + JSONObject firstChoice = choices.getJSONObject(0); + JSONObject message = firstChoice.optJSONObject("message"); + if (message == null) { + LOG.warn("OpenAI response choice contained no message"); + return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT", + "OpenAI response choice contained no message"); + } + String content = message.optString("content", null); + if (content == null || content.isBlank()) { + LOG.warn("OpenAI response message.content is absent or blank"); + return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT", + "OpenAI response message.content is absent or blank"); + } + return new AiInvocationSuccess(request, new AiRawResponse(content)); + } catch (JSONException e) { + LOG.warn("OpenAI response could not be parsed as JSON: {}", e.getMessage()); + return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON", + "OpenAI response body is not valid JSON: " + e.getMessage()); + } + } + /** * Builds an OpenAI Chat Completions API request from the request representation. *

diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java index 901c692..2bd7011 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java @@ -43,8 +43,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; *

* Coverage goals: *

*/ @ExtendWith(MockitoExtension.class) @@ -84,10 +85,11 @@ class OpenAiHttpAdapterTest { } @Test - @DisplayName("should return AiInvocationSuccess when HTTP 200 is received with raw response") + @DisplayName("should extract choices[0].message.content when HTTP 200 is received") void testSuccessfulInvocationWith200Response() throws Exception { // Arrange - String responseBody = "{\"choices\":[{\"message\":{\"content\":\"test response\"}}]}"; + String innerContent = "{\"title\":\"Bodenanalyseergebnis\",\"reasoning\":\"Found soil analysis\",\"date\":\"2025-02-23\"}"; + String responseBody = "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}"; HttpResponse httpResponse = mockHttpResponse(200, responseBody); doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); @@ -96,11 +98,11 @@ class OpenAiHttpAdapterTest { // Act AiInvocationResult result = adapter.invoke(request); - // Assert + // Assert: success, and the raw response content is the extracted inner content, not the full envelope assertThat(result).isInstanceOf(AiInvocationSuccess.class); AiInvocationSuccess success = (AiInvocationSuccess) result; assertThat(success.request()).isEqualTo(request); - assertThat(success.rawResponse().content()).isEqualTo(responseBody); + assertThat(success.rawResponse().content()).isEqualTo(innerContent); } @Test @@ -364,7 +366,8 @@ class OpenAiHttpAdapterTest { @DisplayName("should preserve request in success result") void testSuccessPreservesRequest() throws Exception { // Arrange - HttpResponse httpResponse = mockHttpResponse(200, "{\"result\":\"ok\"}"); + HttpResponse httpResponse = mockHttpResponse(200, + "{\"choices\":[{\"message\":{\"content\":\"{\\\"title\\\":\\\"Test\\\",\\\"reasoning\\\":\\\"r\\\"}\"}}]}"); doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); AiRequestRepresentation request = createTestRequest("Test prompt", "Test document"); @@ -461,7 +464,9 @@ class OpenAiHttpAdapterTest { new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""), httpClient); - HttpResponse httpResponse = mockHttpResponse(200, "{}"); + String innerContent = "{\"title\":\"Test\",\"reasoning\":\"r\"}"; + HttpResponse httpResponse = mockHttpResponse(200, + "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}"); doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); AiRequestRepresentation request = createTestRequest("Test prompt", "Test document"); @@ -525,20 +530,21 @@ class OpenAiHttpAdapterTest { /** * Verifies that adapter behavioral contracts (success mapping, error classification) - * are unchanged after the constructor was changed from StartConfiguration to - * ProviderConfiguration. + * are preserved. HTTP 200 must extract {@code choices[0].message.content} as the + * raw response; non-200 and exceptions must produce typed technical failures. */ @Test @DisplayName("openAiAdapterBehaviorIsUnchanged: HTTP success and error mapping contracts are preserved") void openAiAdapterBehaviorIsUnchanged() throws Exception { - // Success case: HTTP 200 must produce AiInvocationSuccess with raw body - String successBody = "{\"choices\":[{\"message\":{\"content\":\"result\"}}]}"; + // Success case: HTTP 200 must produce AiInvocationSuccess with extracted content + String innerContent = "{\"title\":\"Rechnung\",\"reasoning\":\"r\"}"; + String successBody = "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}"; HttpResponse successResponse = mockHttpResponse(200, successBody); doReturn(successResponse).when(httpClient).send(any(HttpRequest.class), any()); AiInvocationResult result = adapter.invoke(createTestRequest("p", "d")); assertThat(result).isInstanceOf(AiInvocationSuccess.class); - assertThat(((AiInvocationSuccess) result).rawResponse().content()).isEqualTo(successBody); + assertThat(((AiInvocationSuccess) result).rawResponse().content()).isEqualTo(innerContent); // Non-200 case: HTTP 429 must produce AiInvocationTechnicalFailure with HTTP_429 reason HttpResponse rateLimitedResponse = mockHttpResponse(429, null); @@ -570,7 +576,7 @@ class OpenAiHttpAdapterTest { OpenAiHttpAdapter adapterWithPortZero = new OpenAiHttpAdapter(configWithPortZero, httpClient); HttpResponse httpResponse = mockHttpResponse(200, - "{\"choices\":[{\"message\":{\"content\":\"test\"}}]}"); + "{\"choices\":[{\"message\":{\"content\":\"{\\\"title\\\":\\\"T\\\",\\\"reasoning\\\":\\\"r\\\"}\"}}]}"); doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); adapterWithPortZero.invoke(createTestRequest("p", "d")); @@ -582,6 +588,59 @@ class OpenAiHttpAdapterTest { .doesNotContain(":0"); } + @Test + @DisplayName("should extract inner JSON from choices[0].message.content (two-step parse)") + void testTwoStepParseExtractsInnerContentString() throws Exception { + // The OpenAI API wraps the actual AI content as an escaped JSON string inside + // choices[0].message.content. The adapter must perform two-step parsing: + // 1) parse the outer envelope to extract choices[0].message.content, + // 2) return that content string as-is for the Application layer to parse. + String innerContent = "{\"title\":\"Bodenanalyseergebnis\",\"reasoning\":\"Soil analysis report\",\"date\":\"2025-02-23\"}"; + String envelope = "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}"; + + HttpResponse httpResponse = mockHttpResponse(200, envelope); + doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); + + AiRequestRepresentation request = createTestRequest("Test prompt", "Test document"); + + AiInvocationResult result = adapter.invoke(request); + + assertThat(result).isInstanceOf(AiInvocationSuccess.class); + AiInvocationSuccess success = (AiInvocationSuccess) result; + // The raw response content must be the extracted inner string, not the outer envelope + assertThat(success.rawResponse().content()) + .as("Adapter must return the inner content string, not the outer OpenAI envelope") + .isEqualTo(innerContent); + // Verify the extracted content is parseable as the expected NamingProposal JSON + JSONObject parsed = new JSONObject(success.rawResponse().content()); + assertThat(parsed.getString("title")).isEqualTo("Bodenanalyseergebnis"); + assertThat(parsed.getString("date")).isEqualTo("2025-02-23"); + } + + @Test + @DisplayName("should return NO_CHOICE_CONTENT failure when choices array is empty") + void testEmptyChoicesArrayReturnsFailure() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, "{\"choices\":[]}"); + doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); + + AiInvocationResult result = adapter.invoke(createTestRequest("p", "d")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("NO_CHOICE_CONTENT"); + } + + @Test + @DisplayName("should return UNPARSEABLE_JSON failure when HTTP 200 body is not valid JSON") + void testUnparseableEnvelopeReturnsFailure() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, "not-json-at-all"); + doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any()); + + AiInvocationResult result = adapter.invoke(createTestRequest("p", "d")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("UNPARSEABLE_JSON"); + } + // Helper methods /**