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:
+83
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-13
@@ -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\"}]");
|
||||
|
||||
Reference in New Issue
Block a user