|
| 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 | +} |
0 commit comments