From eb970d7c59cb6e94ba405f2ad522187a685e70aa Mon Sep 17 00:00:00 2001 From: Marcus Date: Wed, 1 Jul 2026 18:07:13 +0200 Subject: [PATCH] fix: KI-Antwort mit umgebendem Text/Markdown tolerant parsen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AiResponseParser lehnte jede Antwort ab, die nach dem Trimmen nicht mit '{' begann und mit '}' endete (NOT_JSON_OBJECT). claude-sonnet-5 liefert das JSON gelegentlich mit Einleitungstext oder in ```json ...```-Codeblöcken; solche Antworten scheiterten dann als transienter Fehler und wurden endlos wiederholt (betroffen u.a. Scan260701074355.pdf, sporadisch weitere Dokumente). Der Parser löst nun das erste ausbalancierte JSON-Objekt aus der Antwort heraus (Klammerzählung mit Beachtung von String-Literalen und Escapes) und ignoriert umgebenden Fließtext sowie Markdown-Codeblöcke. Reine JSON-Objekt-Antworten verhalten sich unverändert; ein Top-Level-Array (oder sonstige Nicht-Objekt- Struktur) wird weiterhin als NOT_JSON_OBJECT abgelehnt. Regressionstests ergänzt. Co-Authored-By: Claude Opus 4.8 --- .../application/service/AiResponseParser.java | 95 ++++++++++++++++--- .../service/AiResponseParserTest.java | 90 +++++++++++++++--- 2 files changed, 160 insertions(+), 25 deletions(-) diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParser.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParser.java index 02939dd..c43ded9 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParser.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParser.java @@ -14,10 +14,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse; /** * Parses the raw AI response body into a structurally validated {@link ParsedAiResponse}. *

- * This parser enforces the technical contract: the AI must respond with exactly one + * This parser enforces the technical contract: the AI must provide exactly one * parseable JSON object containing the mandatory fields {@code title} and {@code reasoning}, - * and an optional {@code date} field. Any extra free-text outside the JSON object makes - * the response technically invalid. + * and an optional {@code date} field. Surrounding free-text or Markdown code fences around + * that JSON object are tolerated; the object is extracted before parsing. * *

Parsing rules

* * *

Architecture boundary

@@ -67,11 +68,11 @@ public final class AiResponseParser { return new AiResponseParsingFailure("EMPTY_RESPONSE", "AI response body is empty or blank"); } - String trimmed = body.trim(); - - // Reject if the body does not start with '{' and end with '}' (i.e., not a pure JSON object). - // This catches responses that embed a JSON object within surrounding prose. - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + // Extract the JSON object from the response even when it is surrounded by free-text + // or Markdown code fences. Pure JSON-object responses are unaffected. A top-level + // array (or other non-object structure) is still rejected. + String jsonObject = extractJsonObject(body); + if (jsonObject == null) { return new AiResponseParsingFailure( "NOT_JSON_OBJECT", "AI response is not a pure JSON object (contains extra text or is not an object)"); @@ -79,7 +80,7 @@ public final class AiResponseParser { JSONObject json; try { - json = new JSONObject(trimmed); + json = new JSONObject(jsonObject); } catch (JSONException e) { return new AiResponseParsingFailure("INVALID_JSON", "AI response is not valid JSON: " + e.getMessage()); } @@ -108,4 +109,74 @@ public final class AiResponseParser { ParsedAiResponse parsed = ParsedAiResponse.of(title, reasoning, dateString); return new AiResponseParsingSuccess(parsed); } + + /** + * Extracts the first complete JSON object from the response text. + *

+ * Tolerates leading and trailing free-text as well as Markdown code fences + * (e.g. {@code ```json … ```}) by reading from the first {@code '{'} up to its matching + * {@code '}'}. Brace counting ignores curly braces inside JSON string literals and honours + * escape sequences. + *

+ * If a {@code '['} appears at the top level – before the first {@code '{'} – the response is + * treated as an array (or other non-object structure) and {@code null} is returned. + * + * @param body the raw response body; must not be {@code null} or blank + * @return the substring containing the JSON object, or {@code null} if no complete object was found + */ + private static String extractJsonObject(String body) { + int firstBrace = body.indexOf('{'); + if (firstBrace < 0) { + return null; + } + int firstBracket = body.indexOf('['); + if (firstBracket >= 0 && firstBracket < firstBrace) { + // Array or other non-object structure at the top level. + return null; + } + return balancedObject(body, firstBrace); + } + + /** + * Reads the balanced JSON object starting at {@code start} (the index of an opening {@code '{'}). + *

+ * Counts curly braces while respecting string literals and escape sequences, and returns the + * substring from the opening {@code '{'} up to and including its matching {@code '}'}. + * + * @param body the text to scan + * @param start the index of the opening {@code '{'} + * @return the object substring including both braces, or {@code null} if no balanced object was found + */ + private static String balancedObject(String body, int start) { + boolean inString = false; + boolean escaped = false; + int depth = 0; + for (int i = start; i < body.length(); i++) { + char c = body.charAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + continue; + } + switch (c) { + case '"' -> inString = true; + case '{' -> depth++; + case '}' -> { + depth--; + if (depth == 0) { + return body.substring(start, i + 1); + } + } + default -> { + // ordinary character outside a string – ignored + } + } + } + return null; + } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParserTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParserTest.java index 6019a1b..092282e 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParserTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseParserTest.java @@ -15,7 +15,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse; * Unit tests for {@link AiResponseParser}. *

* Covers structural parsing rules: valid JSON objects, mandatory fields, - * optional date, extra fields, and rejection of non-JSON or mixed-content responses. + * optional date, extra fields, extraction of JSON objects embedded in surrounding + * text or Markdown code fences, and rejection of non-JSON or non-object responses. */ class AiResponseParserTest { @@ -96,6 +97,81 @@ class AiResponseParserTest { assertThat(parsed.dateString()).isEmpty(); } + // ------------------------------------------------------------------------- + // Tolerance: JSON object embedded in surrounding text / Markdown fences + // ------------------------------------------------------------------------- + + @Test + void parse_jsonWrappedInMarkdownFence_extractsObject() { + AiRawResponse raw = new AiRawResponse( + "```json\n{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\"}\n```"); + + AiResponseParsingResult result = AiResponseParser.parse(raw); + + assertThat(result).isInstanceOf(AiResponseParsingSuccess.class); + ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response(); + assertThat(parsed.title()).isEqualTo("Rechnung"); + assertThat(parsed.reasoning()).isEqualTo("Invoice"); + } + + @Test + void parse_jsonWithLeadingProse_extractsObjectIncludingDate() { + AiRawResponse raw = new AiRawResponse( + "Hier ist das Ergebnis:\n{\"title\":\"Stromabrechnung\",\"reasoning\":\"r\",\"date\":\"2026-01-15\"}"); + + AiResponseParsingResult result = AiResponseParser.parse(raw); + + assertThat(result).isInstanceOf(AiResponseParsingSuccess.class); + ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response(); + assertThat(parsed.title()).isEqualTo("Stromabrechnung"); + assertThat(parsed.dateString()).contains("2026-01-15"); + } + + @Test + void parse_jsonWithTrailingProse_extractsObject() { + AiRawResponse raw = new AiRawResponse( + "{\"title\":\"Vertrag\",\"reasoning\":\"r\"}\n\nHope that helps!"); + + AiResponseParsingResult result = AiResponseParser.parse(raw); + + assertThat(result).isInstanceOf(AiResponseParsingSuccess.class); + assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("Vertrag"); + } + + @Test + void parse_bracketInsideStringValue_isNotTreatedAsArray() { + AiRawResponse raw = new AiRawResponse( + "{\"title\":\"Rechnung [Entwurf]\",\"reasoning\":\"contains a [ bracket\"}"); + + AiResponseParsingResult result = AiResponseParser.parse(raw); + + assertThat(result).isInstanceOf(AiResponseParsingSuccess.class); + assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("Rechnung [Entwurf]"); + } + + @Test + void parse_nestedObject_bracesAreBalancedCorrectly() { + AiRawResponse raw = new AiRawResponse( + "prefix {\"title\":\"X\",\"reasoning\":\"r\",\"meta\":{\"a\":1,\"b\":{\"c\":2}}} suffix"); + + AiResponseParsingResult result = AiResponseParser.parse(raw); + + assertThat(result).isInstanceOf(AiResponseParsingSuccess.class); + assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("X"); + } + + @Test + void parse_unbalancedTruncatedObject_returnsFailure() { + AiRawResponse raw = new AiRawResponse( + "{\"title\":\"X\",\"reasoning\":\"r\""); + + AiResponseParsingResult result = AiResponseParser.parse(raw); + + assertThat(result).isInstanceOf(AiResponseParsingFailure.class); + assertThat(((AiResponseParsingFailure) result).failureReason()) + .isEqualTo("NOT_JSON_OBJECT"); + } + // ------------------------------------------------------------------------- // Failure cases – structural // ------------------------------------------------------------------------- @@ -131,18 +207,6 @@ class AiResponseParserTest { .isEqualTo("NOT_JSON_OBJECT"); } - @Test - void parse_jsonEmbeddedInProse_returnsFailure() { - AiRawResponse raw = new AiRawResponse( - "Here is the result: {\"title\":\"Rechnung\",\"reasoning\":\"r\"} Hope that helps!"); - - AiResponseParsingResult result = AiResponseParser.parse(raw); - - assertThat(result).isInstanceOf(AiResponseParsingFailure.class); - assertThat(((AiResponseParsingFailure) result).failureReason()) - .isEqualTo("NOT_JSON_OBJECT"); - } - @Test void parse_jsonArray_returnsFailure() { AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");