Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb970d7c59 |
+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}.
|
* Parses the raw AI response body into a structurally validated {@link ParsedAiResponse}.
|
||||||
* <p>
|
* <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},
|
* 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
|
* and an optional {@code date} field. Surrounding free-text or Markdown code fences around
|
||||||
* the response technically invalid.
|
* that JSON object are tolerated; the object is extracted before parsing.
|
||||||
*
|
*
|
||||||
* <h2>Parsing rules</h2>
|
* <h2>Parsing rules</h2>
|
||||||
* <ul>
|
* <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 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>{@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>Additional JSON fields are tolerated and silently ignored.</li>
|
||||||
* <li>Any free-text outside the outermost JSON object makes the response technically
|
* <li>Leading or trailing free-text and Markdown code fences around the JSON object are
|
||||||
* unacceptable; this is detected by attempting to parse the trimmed body directly
|
* tolerated: the first balanced JSON object is extracted and parsed. A response that
|
||||||
* as a JSON object and rejecting inputs that are not pure JSON objects.</li>
|
* 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>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h2>Architecture boundary</h2>
|
* <h2>Architecture boundary</h2>
|
||||||
@@ -67,11 +68,11 @@ public final class AiResponseParser {
|
|||||||
return new AiResponseParsingFailure("EMPTY_RESPONSE", "AI response body is empty or blank");
|
return new AiResponseParsingFailure("EMPTY_RESPONSE", "AI response body is empty or blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
String trimmed = body.trim();
|
// 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
|
||||||
// Reject if the body does not start with '{' and end with '}' (i.e., not a pure JSON object).
|
// array (or other non-object structure) is still rejected.
|
||||||
// This catches responses that embed a JSON object within surrounding prose.
|
String jsonObject = extractJsonObject(body);
|
||||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
if (jsonObject == null) {
|
||||||
return new AiResponseParsingFailure(
|
return new AiResponseParsingFailure(
|
||||||
"NOT_JSON_OBJECT",
|
"NOT_JSON_OBJECT",
|
||||||
"AI response is not a pure JSON object (contains extra text or is not an 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;
|
JSONObject json;
|
||||||
try {
|
try {
|
||||||
json = new JSONObject(trimmed);
|
json = new JSONObject(jsonObject);
|
||||||
} catch (JSONException e) {
|
} catch (JSONException e) {
|
||||||
return new AiResponseParsingFailure("INVALID_JSON", "AI response is not valid JSON: " + e.getMessage());
|
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);
|
ParsedAiResponse parsed = ParsedAiResponse.of(title, reasoning, dateString);
|
||||||
return new AiResponseParsingSuccess(parsed);
|
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}.
|
* Unit tests for {@link AiResponseParser}.
|
||||||
* <p>
|
* <p>
|
||||||
* Covers structural parsing rules: valid JSON objects, mandatory fields,
|
* 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 {
|
class AiResponseParserTest {
|
||||||
|
|
||||||
@@ -96,6 +97,81 @@ class AiResponseParserTest {
|
|||||||
assertThat(parsed.dateString()).isEmpty();
|
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
|
// Failure cases – structural
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -131,18 +207,6 @@ class AiResponseParserTest {
|
|||||||
.isEqualTo("NOT_JSON_OBJECT");
|
.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
|
@Test
|
||||||
void parse_jsonArray_returnsFailure() {
|
void parse_jsonArray_returnsFailure() {
|
||||||
AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");
|
AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");
|
||||||
|
|||||||
Reference in New Issue
Block a user