fix: KI-Antwort mit umgebendem Text/Markdown tolerant parsen

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:07:13 +02:00
parent 76daddbda2
commit d5bd041c15
2 changed files with 160 additions and 25 deletions
@@ -14,10 +14,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
/**
* Parses the raw AI response body into a structurally validated {@link ParsedAiResponse}.
* <p>
* 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.
*
* <h2>Parsing rules</h2>
* <ul>
@@ -26,9 +26,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
* <li>{@code reasoning} must be present (may be empty in degenerate cases, but must exist).</li>
* <li>{@code date} is optional; if absent the field is modelled as an empty Optional.</li>
* <li>Additional JSON fields are tolerated and silently ignored.</li>
* <li>Any free-text outside the outermost JSON object makes the response technically
* unacceptable; this is detected by attempting to parse the trimmed body directly
* as a JSON object and rejecting inputs that are not pure JSON objects.</li>
* <li>Leading or trailing free-text and Markdown code fences around the JSON object are
* tolerated: the first balanced JSON object is extracted and parsed. A response that
* contains no JSON object, or where an array (or other non-object) appears at the top
* level, is rejected as {@code NOT_JSON_OBJECT}.</li>
* </ul>
*
* <h2>Architecture boundary</h2>
@@ -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.
* <p>
* 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.
* <p>
* 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 '{'}).
* <p>
* 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;
}
}
@@ -15,7 +15,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
* Unit tests for {@link AiResponseParser}.
* <p>
* 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\"}]");