Skip to content

Commit accc626

Browse files
committed
✨ [AI]: Add JSON healing mechanism for useGenerateObject
Implement multi-stage JSON repair to handle malformed AI responses: - Extract embedded JSON from text/markdown - Clean format issues (comments, quotes, trailing commas) - Repair incomplete JSON (balance brackets) Added enableJsonHealing option (default: true)
1 parent 62899b5 commit accc626

File tree

4 files changed

+453
-11
lines changed

4 files changed

+453
-11
lines changed

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/usegenerateobject/GenerateObjectOptions.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ typealias OnObjectFinishCallback<T> = (obj: T, usage: ChatUsage?) -> Unit
3434
* @property timeout Request timeout duration
3535
* @property headers Additional HTTP headers to send with requests
3636
* @property httpEngine Custom HTTP engine (null = use global default)
37+
* @property enableJsonHealing Enable JSON repair mechanism (default true)
3738
* @property onResponse Callback when receiving an HTTP response
3839
* @property onFinish Callback when object generation is complete
3940
* @property onError Callback when an error occurs
@@ -48,6 +49,17 @@ data class GenerateObjectOptions<T> internal constructor(
4849
override var timeout: Duration = AIOptionsDefaults.DEFAULT_TIMEOUT,
4950
override var headers: Map<String, String> = AIOptionsDefaults.DEFAULT_HEADERS,
5051
override var httpEngine: HttpEngine? = null,
52+
/**
53+
* Enable JSON repair mechanism, default is true.
54+
*
55+
* When enabled, automatically handles:
56+
* - Extract embedded JSON from text
57+
* - Clean format issues (comments, quotes, trailing commas)
58+
* - Repair incomplete JSON (balance brackets)
59+
*
60+
* When false, only performs basic markdown cleaning.
61+
*/
62+
var enableJsonHealing: Boolean = true,
5163
// Callbacks
5264
override var onResponse: OnResponseCallback? = null,
5365
var onFinish: OnObjectFinishCallback<T>? = null,
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package xyz.junerver.compose.ai.usegenerateobject
2+
3+
/*
4+
Description: JSON healing utilities for handling malformed AI responses
5+
Author: Junerver
6+
Date: 2026/01/06
7+
Email: junerver@gmail.com
8+
Version: v1.0
9+
10+
Provides multi-stage JSON repair:
11+
1. Extract JSON from text (remove markdown, find JSON boundaries)
12+
2. Clean format issues (comments, quotes, trailing commas)
13+
3. Repair structure (balance brackets)
14+
*/
15+
16+
/**
17+
* Main entry point for JSON healing.
18+
*
19+
* Applies multi-stage repair to handle common issues in AI-generated JSON:
20+
* - Embedded JSON in text
21+
* - Markdown code blocks
22+
* - Comments (// and /* */)
23+
* - Single quotes instead of double quotes
24+
* - Trailing commas
25+
* - Incomplete JSON (missing closing brackets)
26+
*
27+
* @param raw The raw response text from AI
28+
* @param enableHealing Whether to enable healing (default true)
29+
* @return Repaired JSON string ready for parsing
30+
*/
31+
internal fun healJson(raw: String, enableHealing: Boolean = true): String {
32+
if (!enableHealing) {
33+
return cleanJsonResponse(raw) // Fallback to simple cleaning
34+
}
35+
36+
if (raw.isBlank()) {
37+
return raw
38+
}
39+
40+
var json = raw
41+
json = extractJson(json) // Stage 1: Extract JSON
42+
json = cleanJsonFormat(json) // Stage 2: Clean format
43+
json = repairJsonStructure(json) // Stage 3: Repair structure
44+
return json
45+
}
46+
47+
/**
48+
* Cleans up potential markdown code blocks from JSON response.
49+
*/
50+
internal fun cleanJsonResponse(raw: String): String {
51+
var result = raw.trim()
52+
.removePrefix("```json")
53+
.removePrefix("```")
54+
.removeSuffix("```")
55+
.trim()
56+
57+
// Remove trailing commas
58+
result = result.replace(Regex(",\\s*}"), "}")
59+
result = result.replace(Regex(",\\s*]"), "]")
60+
61+
return result
62+
}
63+
64+
/**
65+
* Stage 1: Extract JSON from text.
66+
*
67+
* Handles:
68+
* - Markdown code blocks (```json ... ```)
69+
* - Embedded JSON in text ("Here's the result: {...}")
70+
*
71+
* @param text The raw text containing JSON
72+
* @return Extracted JSON string
73+
*/
74+
private fun extractJson(text: String): String {
75+
// 1. Remove markdown code blocks
76+
var cleaned = cleanJsonResponse(text)
77+
78+
// 2. Find JSON boundaries using proper bracket matching
79+
val firstBrace = cleaned.indexOfAny(charArrayOf('{', '['))
80+
if (firstBrace < 0) return cleaned
81+
82+
// Find matching closing bracket
83+
var depth = 0
84+
var inString = false
85+
var escapeNext = false
86+
var lastBrace = -1
87+
88+
for (i in firstBrace until cleaned.length) {
89+
val char = cleaned[i]
90+
when {
91+
escapeNext -> escapeNext = false
92+
char == '\\' && inString -> escapeNext = true
93+
char == '"' -> inString = !inString
94+
!inString -> {
95+
when (char) {
96+
'{', '[' -> depth++
97+
'}', ']' -> {
98+
depth--
99+
if (depth == 0) {
100+
lastBrace = i
101+
break
102+
}
103+
}
104+
}
105+
}
106+
}
107+
}
108+
109+
// If we found a matching bracket, extract up to it
110+
// Otherwise, extract everything from firstBrace to end (incomplete JSON)
111+
cleaned = if (lastBrace > firstBrace) {
112+
cleaned.substring(firstBrace, lastBrace + 1)
113+
} else {
114+
cleaned.substring(firstBrace)
115+
}
116+
117+
return cleaned
118+
}
119+
120+
/**
121+
* Stage 2: Clean format issues.
122+
*
123+
* Applies multiple cleaning operations:
124+
* - Remove comments
125+
* - Fix quotes
126+
* - Remove trailing commas
127+
*
128+
* @param json The JSON string to clean
129+
* @return Cleaned JSON string
130+
*/
131+
private fun cleanJsonFormat(json: String): String {
132+
var result = json
133+
result = removeComments(result)
134+
result = fixQuotes(result)
135+
result = removeTrailingCommas(result)
136+
result = compactWhitespace(result)
137+
return result
138+
}
139+
140+
/**
141+
* Remove comments from JSON.
142+
*
143+
* Handles:
144+
* - Single-line comments: // ...
145+
* - Multi-line comments: /* ... */
146+
*
147+
* @param json The JSON string
148+
* @return JSON without comments
149+
*/
150+
private fun removeComments(json: String): String {
151+
var result = json
152+
// Remove single-line comments // and the newline after them
153+
result = result.replace(Regex("//[^\n]*\n"), "")
154+
// Remove single-line comments at end of string (no newline after)
155+
result = result.replace(Regex("//[^\n]*$"), "")
156+
// Remove multi-line comments /* */ (use [\s\S] for KMP compatibility instead of DOT_MATCHES_ALL)
157+
result = result.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
158+
return result
159+
}
160+
161+
/**
162+
* Fix quote issues.
163+
*
164+
* Converts single quotes to double quotes in property names.
165+
* Example: {'name': 'value'} -> {"name": 'value'}
166+
*
167+
* Note: This is a simplified implementation that only handles property names.
168+
* It may not handle all edge cases (e.g., quotes inside string values).
169+
*
170+
* @param json The JSON string
171+
* @return JSON with fixed quotes
172+
*/
173+
private fun fixQuotes(json: String): String {
174+
// Replace single quotes in property names: 'key': -> "key":
175+
return json.replace(Regex("'([^']*?)':"), "\"$1\":")
176+
}
177+
178+
/**
179+
* Remove trailing commas.
180+
*
181+
* Handles:
182+
* - Trailing commas in objects: {a: 1,} -> {a: 1}
183+
* - Trailing commas in arrays: [1, 2,] -> [1, 2]
184+
*
185+
* @param json The JSON string
186+
* @return JSON without trailing commas
187+
*/
188+
private fun removeTrailingCommas(json: String): String {
189+
var result = json
190+
// Remove trailing commas before }
191+
result = result.replace(Regex(",\\s*}"), "}")
192+
// Remove trailing commas before ]
193+
result = result.replace(Regex(",\\s*]"), "]")
194+
return result
195+
}
196+
197+
/**
198+
* Compact whitespace in JSON.
199+
*
200+
* Removes unnecessary whitespace while preserving string content.
201+
*
202+
* @param json The JSON string
203+
* @return JSON with compacted whitespace
204+
*/
205+
private fun compactWhitespace(json: String): String {
206+
val result = StringBuilder()
207+
var inString = false
208+
var escapeNext = false
209+
210+
for (char in json) {
211+
when {
212+
escapeNext -> {
213+
escapeNext = false
214+
result.append(char)
215+
}
216+
char == '\\' && inString -> {
217+
escapeNext = true
218+
result.append(char)
219+
}
220+
char == '"' -> {
221+
inString = !inString
222+
result.append(char)
223+
}
224+
inString -> {
225+
result.append(char)
226+
}
227+
char.isWhitespace() -> {
228+
// Skip whitespace outside strings
229+
}
230+
else -> {
231+
result.append(char)
232+
}
233+
}
234+
}
235+
236+
return result.toString()
237+
}
238+
239+
/**
240+
* Stage 3: Repair JSON structure.
241+
*
242+
* Currently only handles bracket balancing.
243+
*
244+
* @param json The JSON string
245+
* @return JSON with repaired structure
246+
*/
247+
private fun repairJsonStructure(json: String): String = balanceBrackets(json)
248+
249+
/**
250+
* Balance brackets by adding missing closing brackets.
251+
*
252+
* Uses a stack-based algorithm to track unclosed brackets and
253+
* appends the missing closing brackets at the end.
254+
*
255+
* Example: {"name": "John", "items": [1, 2 -> {"name": "John", "items": [1, 2]}
256+
*
257+
* @param json The JSON string
258+
* @return JSON with balanced brackets
259+
*/
260+
private fun balanceBrackets(json: String): String {
261+
val stack = mutableListOf<Char>()
262+
val pairs = mapOf('{' to '}', '[' to ']')
263+
val closingToOpening = mapOf('}' to '{', ']' to '[')
264+
var inString = false
265+
var escapeNext = false
266+
val result = StringBuilder()
267+
268+
// Track unclosed brackets and remove mismatched closing brackets
269+
for (char in json) {
270+
when {
271+
escapeNext -> {
272+
escapeNext = false
273+
result.append(char)
274+
}
275+
char == '\\' && inString -> {
276+
escapeNext = true
277+
result.append(char)
278+
}
279+
char == '"' -> {
280+
inString = !inString
281+
result.append(char)
282+
}
283+
!inString -> {
284+
when (char) {
285+
'{', '[' -> {
286+
stack.add(char)
287+
result.append(char)
288+
}
289+
'}', ']' -> {
290+
val expected = closingToOpening[char]
291+
if (stack.lastOrNull() == expected) {
292+
stack.removeLastOrNull()
293+
result.append(char)
294+
}
295+
// Ignore mismatched closing brackets
296+
}
297+
else -> result.append(char)
298+
}
299+
}
300+
else -> result.append(char)
301+
}
302+
}
303+
304+
// Append missing closing brackets
305+
val missing = stack.reversed().mapNotNull { pairs[it] }.joinToString("")
306+
return result.toString() + missing
307+
}

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/usegenerateobject/useGenerateObject.kt

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,6 @@ private fun buildSchemaSystemPrompt(userSystemPrompt: String?, schema: String):
119119
""".trimMargin()
120120
}
121121

122-
/**
123-
* Cleans up potential markdown code blocks from JSON response.
124-
*/
125-
private fun cleanJsonResponse(raw: String): String = raw
126-
.trim()
127-
.removePrefix("```json")
128-
.removePrefix("```")
129-
.removeSuffix("```")
130-
.trim()
131-
132122
/**
133123
* A Composable hook for generating structured objects from AI responses.
134124
*
@@ -248,7 +238,7 @@ fun <T : Any> useGenerateObject(
248238
// Parse JSON to object
249239
val rawJson = message.textContent
250240
try {
251-
val cleanJson = cleanJsonResponse(rawJson)
241+
val cleanJson = healJson(rawJson, optionsRef.current.enableJsonHealing)
252242
val obj = defaultJson.decodeFromString(serializerRef.current, cleanJson)
253243
parsedObject.value = obj
254244
parseError.value = null

0 commit comments

Comments
 (0)