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. The repair pipeline
6//! lives in [`lc_shared::json_repair`] so that every parser of LLM JSON —
7//! including `ToolCall::parse_arguments` in `lc-shared` — shares one tolerant
8//! parser. This module keeps the historical public API (`parse_llm_json`,
9//! `parse_llm_json_with_retry`, `LlmJsonParseError`) and delegates to it.
10
11use lc_shared::json_repair::{parse_tolerant_json, JsonRepairError};
12use serde::de::DeserializeOwned;
13
14/// Error types for LLM JSON parsing.
15#[derive(Debug, thiserror::Error)]
16pub enum LlmJsonParseError {
17    /// The raw text could not be repaired into valid JSON.
18    #[error("JSON repair failed: {0}")]
19    RepairFailed(String),
20
21    /// The repaired JSON could not be deserialized into the target type.
22    #[error("Deserialization failed: {details}")]
23    DeserializationFailed { details: String },
24
25    /// All retry attempts failed.
26    #[error("All {attempts} retry attempts failed")]
27    RetryExhausted { attempts: usize },
28}
29
30impl From<JsonRepairError> for LlmJsonParseError {
31    fn from(e: JsonRepairError) -> Self {
32        match e {
33            JsonRepairError::RepairFailed(msg) => LlmJsonParseError::RepairFailed(msg),
34            JsonRepairError::DeserializationFailed { details } => {
35                LlmJsonParseError::DeserializationFailed { details }
36            }
37        }
38    }
39}
40
41/// Parses LLM JSON output with automatic repair of common errors.
42///
43/// Applies these repairs in order:
44/// 1. Strip markdown code fences (```json ... ```)
45/// 2. Find the outermost JSON bracket pair
46/// 3. Fix unescaped inner quotes within string values (heuristic)
47/// 4. Remove trailing commas before `}` or `]`
48/// 5. Truncate to the last matching `}` or `]` if there's trailing garbage
49///
50/// Returns the deserialized value or an error if repair fails.
51pub fn parse_llm_json<T: DeserializeOwned>(raw: &str) -> Result<T, LlmJsonParseError> {
52    parse_tolerant_json::<T>(raw).map_err(Into::into)
53}
54
55/// Parses LLM JSON with retry: if parsing fails, calls the provided callback
56/// to get a corrected response from the LLM, and tries again.
57///
58/// The callback receives the original raw text and the parse error message,
59/// and should return the LLM's corrected output. Up to `max_retries` attempts
60/// are made.
61pub async fn parse_llm_json_with_retry<T, F, Fut>(
62    raw: &str,
63    max_retries: usize,
64    retry_callback: F,
65) -> Result<T, LlmJsonParseError>
66where
67    T: DeserializeOwned,
68    F: Fn(&str, &str) -> Fut,
69    Fut: std::future::Future<Output = Result<String, String>>,
70{
71    let mut current_raw = raw.to_string();
72
73    for attempt in 0..=max_retries {
74        match parse_llm_json::<T>(&current_raw) {
75            Ok(value) => return Ok(value),
76            Err(e) if attempt < max_retries => {
77                let error_msg = e.to_string();
78                let corrected = retry_callback(&current_raw, &error_msg)
79                    .await
80                    .map_err(|_| LlmJsonParseError::RetryExhausted {
81                        attempts: attempt + 1,
82                    })?;
83                current_raw = corrected;
84            }
85            Err(_) => {
86                return Err(LlmJsonParseError::RetryExhausted {
87                    attempts: attempt + 1,
88                });
89            }
90        }
91    }
92
93    Err(LlmJsonParseError::RetryExhausted {
94        attempts: max_retries + 1,
95    })
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use serde::Deserialize;
102
103    #[derive(Debug, Deserialize, PartialEq)]
104    struct TestStruct {
105        name: String,
106        value: i32,
107    }
108
109    #[test]
110    fn test_parse_valid_json() {
111        let raw = r#"{"name": "test", "value": 42}"#;
112        let result: TestStruct = parse_llm_json(raw).unwrap();
113        assert_eq!(result.name, "test");
114        assert_eq!(result.value, 42);
115    }
116
117    #[test]
118    fn test_parse_json_with_code_fence() {
119        let raw = "```json\n{\"name\": \"test\", \"value\": 42}\n```";
120        let result: TestStruct = parse_llm_json(raw).unwrap();
121        assert_eq!(result.name, "test");
122    }
123
124    #[test]
125    fn test_parse_json_with_trailing_comma() {
126        let raw = r#"{"name": "test", "value": 42,}"#;
127        let result: TestStruct = parse_llm_json(raw).unwrap();
128        assert_eq!(result.name, "test");
129        assert_eq!(result.value, 42);
130    }
131
132    #[test]
133    fn test_parse_json_with_surrounding_text() {
134        let raw = "Here is the result: {\"name\": \"test\", \"value\": 42} done.";
135        let result: TestStruct = parse_llm_json(raw).unwrap();
136        assert_eq!(result.name, "test");
137    }
138
139    #[test]
140    fn test_parse_json_with_trailing_garbage() {
141        let raw = r#"{"name": "test", "value": 42} and some extra text"#;
142        let result: TestStruct = parse_llm_json(raw).unwrap();
143        assert_eq!(result.name, "test");
144    }
145
146    #[test]
147    fn test_parse_json_with_unescaped_inner_quotes() {
148        let raw = r#"{"name": "He said "hi" and left", "value": 1}"#;
149        let result: TestStruct = parse_llm_json(raw).unwrap();
150        assert_eq!(result.name, "He said \"hi\" and left");
151        assert_eq!(result.value, 1);
152    }
153
154    #[test]
155    fn test_parse_json_array_with_trailing_comma() {
156        let raw = r#"[{"name": "a", "value": 1}, {"name": "b", "value": 2},]"#;
157        let result: Vec<TestStruct> = parse_llm_json(raw).unwrap();
158        assert_eq!(result.len(), 2);
159    }
160
161    #[test]
162    fn test_parse_empty_text_fails() {
163        let result: Result<TestStruct, _> = parse_llm_json("");
164        assert!(result.is_err());
165    }
166
167    #[test]
168    fn test_parse_no_json_content_fails() {
169        let result: Result<TestStruct, _> = parse_llm_json("just some plain text");
170        assert!(result.is_err());
171    }
172
173    #[test]
174    fn test_parse_json_no_closing_fence() {
175        let raw = "```json\n{\"name\": \"test\", \"value\": 42}";
176        let result: TestStruct = parse_llm_json(raw).unwrap();
177        assert_eq!(result.name, "test");
178    }
179
180    #[test]
181    fn test_error_display() {
182        let err = LlmJsonParseError::RepairFailed("no json".to_string());
183        assert!(err.to_string().contains("no json"));
184
185        let err = LlmJsonParseError::RetryExhausted { attempts: 3 };
186        assert!(err.to_string().contains("3"));
187    }
188
189    #[tokio::test]
190    async fn test_parse_with_retry_succeeds_on_first_try() {
191        let raw = r#"{"name": "test", "value": 42}"#;
192        let result: TestStruct = parse_llm_json_with_retry(raw, 2, |_raw, _err| async {
193            Ok("should not be called".to_string())
194        })
195        .await
196        .unwrap();
197        assert_eq!(result.name, "test");
198    }
199
200    #[tokio::test]
201    async fn test_parse_with_retry_succeeds_on_second_try() {
202        let bad_raw = "not json at all";
203        let good_raw = r#"{"name": "retry", "value": 7}"#;
204        let result: TestStruct =
205            parse_llm_json_with_retry(bad_raw, 2, |_raw, _err| async { Ok(good_raw.to_string()) })
206                .await
207                .unwrap();
208        assert_eq!(result.name, "retry");
209        assert_eq!(result.value, 7);
210    }
211
212    #[tokio::test]
213    async fn test_parse_with_retry_fails_all_attempts() {
214        let bad_raw = "not json";
215        let result: Result<TestStruct, _> =
216            parse_llm_json_with_retry(bad_raw, 1, |_raw, _err| async {
217                Ok("still not json".to_string())
218            })
219            .await;
220        assert!(result.is_err());
221    }
222}