Skip to main content

lc_core/
json_parse.rs

1// src/core/json_parse.rs
2//! Tolerant JSON parsing for LLM outputs.
3//!
4//! LLMs frequently produce malformed JSON: trailing commas, unescaped quotes,
5//! incomplete brackets, or markdown-wrapped code blocks. This module provides
6//! utilities to handle these cases gracefully.
7
8use serde::de::DeserializeOwned;
9
10/// Error types for LLM JSON parsing.
11#[derive(Debug, thiserror::Error)]
12pub enum LlmJsonParseError {
13    /// The raw text could not be repaired into valid JSON.
14    #[error("JSON repair failed: {0}")]
15    RepairFailed(String),
16
17    /// The repaired JSON could not be deserialized into the target type.
18    #[error("Deserialization failed: {details}")]
19    DeserializationFailed { details: String },
20
21    /// All retry attempts failed.
22    #[error("All {attempts} retry attempts failed")]
23    RetryExhausted { attempts: usize },
24}
25
26/// Parses LLM JSON output with automatic repair of common errors.
27///
28/// Applies these repairs in order:
29/// 1. Strip markdown code fences (```json ... ```)
30/// 2. Find the outermost JSON bracket pair
31/// 3. Remove trailing commas before `}` or `]`
32/// 4. Fix unescaped inner quotes within string values (heuristic)
33/// 5. Truncate to the last matching `}` or `]` if there's trailing garbage
34///
35/// Returns the deserialized value or an error if repair fails.
36pub fn parse_llm_json<T: DeserializeOwned>(raw: &str) -> Result<T, LlmJsonParseError> {
37    let repaired = repair_json(raw)?;
38    serde_json::from_str::<T>(&repaired).map_err(|e| LlmJsonParseError::DeserializationFailed {
39        details: format!("{} (repaired JSON: {})", e, truncate(&repaired, 200)),
40    })
41}
42
43/// Parses LLM JSON with retry: if parsing fails, calls the provided callback
44/// to get a corrected response from the LLM, and tries again.
45///
46/// The callback receives the original raw text and the parse error message,
47/// and should return the LLM's corrected output. Up to `max_retries` attempts
48/// are made.
49pub async fn parse_llm_json_with_retry<T, F, Fut>(
50    raw: &str,
51    max_retries: usize,
52    retry_callback: F,
53) -> Result<T, LlmJsonParseError>
54where
55    T: DeserializeOwned,
56    F: Fn(&str, &str) -> Fut,
57    Fut: std::future::Future<Output = Result<String, String>>,
58{
59    let mut current_raw = raw.to_string();
60
61    for attempt in 0..=max_retries {
62        match parse_llm_json::<T>(&current_raw) {
63            Ok(value) => return Ok(value),
64            Err(e) if attempt < max_retries => {
65                let error_msg = e.to_string();
66                let corrected = retry_callback(&current_raw, &error_msg)
67                    .await
68                    .map_err(|_| LlmJsonParseError::RetryExhausted {
69                        attempts: attempt + 1,
70                    })?;
71                current_raw = corrected;
72            }
73            Err(_) => {
74                return Err(LlmJsonParseError::RetryExhausted {
75                    attempts: attempt + 1,
76                });
77            }
78        }
79    }
80
81    Err(LlmJsonParseError::RetryExhausted {
82        attempts: max_retries + 1,
83    })
84}
85
86/// Repairs common LLM JSON mistakes and extracts the JSON portion.
87fn repair_json(raw: &str) -> Result<String, LlmJsonParseError> {
88    let trimmed = raw.trim();
89
90    // Step 1: Strip markdown code fences
91    let stripped = strip_code_fences(trimmed);
92
93    // Step 2: Find outermost JSON bracket pair
94    let extracted = extract_bracket_pair(&stripped);
95
96    // Step 3: Remove trailing commas before } or ]
97    let no_trailing = remove_trailing_commas(&extracted);
98
99    // Step 4: Try to truncate trailing garbage
100    let truncated = truncate_to_matching_bracket(&no_trailing);
101
102    // Verify the result is at least syntactically plausible
103    if truncated.is_empty() {
104        return Err(LlmJsonParseError::RepairFailed(
105            "no JSON content found".to_string(),
106        ));
107    }
108
109    Ok(truncated)
110}
111
112/// Strips markdown code fences from the text.
113fn strip_code_fences(text: &str) -> String {
114    let trimmed = text.trim();
115    if let Some(rest) = trimmed.strip_prefix("```json") {
116        if let Some(end) = rest.find("```") {
117            return rest[..end].trim().to_string();
118        }
119        // No closing fence — strip prefix only
120        return rest.trim().to_string();
121    }
122    if let Some(rest) = trimmed.strip_prefix("```") {
123        if let Some(end) = rest.find("```") {
124            return rest[..end].trim().to_string();
125        }
126        return rest.trim().to_string();
127    }
128    trimmed.to_string()
129}
130
131/// Finds the outermost `[...]` or `{...}` bracket pair.
132fn extract_bracket_pair(text: &str) -> String {
133    let bytes = text.as_bytes();
134    let start_idx = text.find(['[', '{']);
135
136    if let Some(start) = start_idx {
137        let open = bytes[start];
138        let close = if open == b'[' { b']' } else { b'}' };
139
140        let mut depth = 0i32;
141        let mut in_string = false;
142        let mut escape_next = false;
143
144        for i in start..bytes.len() {
145            let ch = bytes[i];
146            if escape_next {
147                escape_next = false;
148                continue;
149            }
150            if ch == b'\\' && in_string {
151                escape_next = true;
152                continue;
153            }
154            if ch == b'"' {
155                in_string = !in_string;
156                continue;
157            }
158            if in_string {
159                continue;
160            }
161            if ch == open {
162                depth += 1;
163            } else if ch == close {
164                depth -= 1;
165                if depth == 0 {
166                    return text[start..=i].to_string();
167                }
168            }
169        }
170    }
171
172    text.to_string()
173}
174
175/// Removes trailing commas before closing brackets.
176///
177/// Handles patterns like: `[...,]` or `{...:,}` → `[...,]` or `{...:,}`
178fn remove_trailing_commas(json: &str) -> String {
179    let mut result = String::with_capacity(json.len());
180    let chars: Vec<char> = json.chars().collect();
181    let len = chars.len();
182
183    for i in 0..len {
184        let ch = chars[i];
185        // Check if this comma is followed by ] or } (possibly with whitespace)
186        if ch == ',' {
187            let mut j = i + 1;
188            while j < len && chars[j].is_whitespace() {
189                j += 1;
190            }
191            if j < len && (chars[j] == ']' || chars[j] == '}') {
192                // Skip this trailing comma
193                continue;
194            }
195        }
196        result.push(ch);
197    }
198
199    result
200}
201
202/// Truncates to the last matching closing bracket if there's trailing garbage.
203fn truncate_to_matching_bracket(json: &str) -> String {
204    // If the JSON already ends with ] or }, it's likely complete.
205    let trimmed = json.trim_end();
206    if trimmed.ends_with(']') || trimmed.ends_with('}') {
207        return trimmed.to_string();
208    }
209
210    // Find the last closing bracket
211    if let Some(last_close) = trimmed.rfind([']', '}']) {
212        return trimmed[..=last_close].to_string();
213    }
214
215    json.to_string()
216}
217
218/// Truncates a string for display purposes.
219fn truncate(s: &str, max_len: usize) -> String {
220    if s.len() <= max_len {
221        s.to_string()
222    } else {
223        let end = s
224            .char_indices()
225            .take(max_len)
226            .last()
227            .map(|(i, _)| i)
228            .unwrap_or(0);
229        format!("{}...", &s[..end])
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use serde::Deserialize;
237
238    #[derive(Debug, Deserialize, PartialEq)]
239    struct TestStruct {
240        name: String,
241        value: i32,
242    }
243
244    #[test]
245    fn test_parse_valid_json() {
246        let raw = r#"{"name": "test", "value": 42}"#;
247        let result: TestStruct = parse_llm_json(raw).unwrap();
248        assert_eq!(result.name, "test");
249        assert_eq!(result.value, 42);
250    }
251
252    #[test]
253    fn test_parse_json_with_code_fence() {
254        let raw = "```json\n{\"name\": \"test\", \"value\": 42}\n```";
255        let result: TestStruct = parse_llm_json(raw).unwrap();
256        assert_eq!(result.name, "test");
257    }
258
259    #[test]
260    fn test_parse_json_with_trailing_comma() {
261        let raw = r#"{"name": "test", "value": 42,}"#;
262        let result: TestStruct = parse_llm_json(raw).unwrap();
263        assert_eq!(result.name, "test");
264        assert_eq!(result.value, 42);
265    }
266
267    #[test]
268    fn test_parse_json_with_surrounding_text() {
269        let raw = "Here is the result: {\"name\": \"test\", \"value\": 42} done.";
270        let result: TestStruct = parse_llm_json(raw).unwrap();
271        assert_eq!(result.name, "test");
272    }
273
274    #[test]
275    fn test_parse_json_with_trailing_garbage() {
276        let raw = r#"{"name": "test", "value": 42} and some extra text"#;
277        let result: TestStruct = parse_llm_json(raw).unwrap();
278        assert_eq!(result.name, "test");
279    }
280
281    #[test]
282    fn test_parse_json_array_with_trailing_comma() {
283        let raw = r#"[{"name": "a", "value": 1}, {"name": "b", "value": 2},]"#;
284        let result: Vec<TestStruct> = parse_llm_json(raw).unwrap();
285        assert_eq!(result.len(), 2);
286    }
287
288    #[test]
289    fn test_parse_empty_text_fails() {
290        let result: Result<TestStruct, _> = parse_llm_json("");
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_parse_no_json_content_fails() {
296        let result: Result<TestStruct, _> = parse_llm_json("just some plain text");
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn test_strip_code_fences_json() {
302        let input = "```json\n{\"key\": \"val\"}\n```";
303        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
304    }
305
306    #[test]
307    fn test_strip_code_fences_plain() {
308        let input = "```\n{\"key\": \"val\"}\n```";
309        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
310    }
311
312    #[test]
313    fn test_strip_code_fences_no_fence() {
314        let input = "{\"key\": \"val\"}";
315        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
316    }
317
318    #[test]
319    fn test_remove_trailing_commas_object() {
320        let input = r#"{"a": 1, "b": 2,}"#;
321        assert_eq!(remove_trailing_commas(input), r#"{"a": 1, "b": 2}"#);
322    }
323
324    #[test]
325    fn test_remove_trailing_commas_array() {
326        let input = r#"[1, 2, 3,]"#;
327        assert_eq!(remove_trailing_commas(input), r#"[1, 2, 3]"#);
328    }
329
330    #[test]
331    fn test_remove_trailing_commas_nested() {
332        let input = r#"{"arr": [1, 2,], "val": 3,}"#;
333        assert_eq!(
334            remove_trailing_commas(input),
335            r#"{"arr": [1, 2], "val": 3}"#
336        );
337    }
338
339    #[test]
340    fn test_extract_bracket_pair_array() {
341        let input = "prefix [1, 2, 3] suffix";
342        assert_eq!(extract_bracket_pair(input), "[1, 2, 3]");
343    }
344
345    #[test]
346    fn test_extract_bracket_pair_object() {
347        let input = r#"text {"a": 1} more"#;
348        assert_eq!(extract_bracket_pair(input), r#"{"a": 1}"#);
349    }
350
351    #[test]
352    fn test_parse_json_no_closing_fence() {
353        let raw = "```json\n{\"name\": \"test\", \"value\": 42}";
354        let result: TestStruct = parse_llm_json(raw).unwrap();
355        assert_eq!(result.name, "test");
356    }
357
358    #[test]
359    fn test_error_display() {
360        let err = LlmJsonParseError::RepairFailed("no json".to_string());
361        assert!(err.to_string().contains("no json"));
362
363        let err = LlmJsonParseError::RetryExhausted { attempts: 3 };
364        assert!(err.to_string().contains("3"));
365    }
366
367    #[tokio::test]
368    async fn test_parse_with_retry_succeeds_on_first_try() {
369        let raw = r#"{"name": "test", "value": 42}"#;
370        let result: TestStruct = parse_llm_json_with_retry(raw, 2, |_raw, _err| async {
371            Ok("should not be called".to_string())
372        })
373        .await
374        .unwrap();
375        assert_eq!(result.name, "test");
376    }
377
378    #[tokio::test]
379    async fn test_parse_with_retry_succeeds_on_second_try() {
380        let bad_raw = "not json at all";
381        let good_raw = r#"{"name": "retry", "value": 7}"#;
382        let result: TestStruct =
383            parse_llm_json_with_retry(bad_raw, 2, |_raw, _err| async { Ok(good_raw.to_string()) })
384                .await
385                .unwrap();
386        assert_eq!(result.name, "retry");
387        assert_eq!(result.value, 7);
388    }
389
390    #[tokio::test]
391    async fn test_parse_with_retry_fails_all_attempts() {
392        let bad_raw = "not json";
393        let result: Result<TestStruct, _> =
394            parse_llm_json_with_retry(bad_raw, 1, |_raw, _err| async {
395                Ok("still not json".to_string())
396            })
397            .await;
398        assert!(result.is_err());
399    }
400}