llm_kernel/llm/
json_extract.rs1use serde::de::DeserializeOwned;
11
12pub fn extract_json(text: &str) -> Option<String> {
16 let text = text.trim();
17
18 if let Some(extracted) = extract_fenced(text, "json") {
20 return Some(extracted);
21 }
22
23 if let Some(extracted) = extract_fenced(text, "")
25 && looks_like_json(&extracted)
26 {
27 return Some(extracted);
28 }
29
30 find_balanced_json(text)
32}
33
34pub fn parse_json<T: DeserializeOwned>(text: &str) -> Result<T, ParseJsonError> {
38 let json_str = extract_json(text).ok_or(ParseJsonError::NoJsonFound)?;
39 serde_json::from_str(&json_str).map_err(ParseJsonError::Deserialize)
40}
41
42#[derive(Debug)]
44pub enum ParseJsonError {
45 NoJsonFound,
47 Deserialize(serde_json::Error),
49}
50
51impl std::fmt::Display for ParseJsonError {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::NoJsonFound => write!(f, "no JSON found in LLM output"),
55 Self::Deserialize(e) => write!(f, "JSON parse error: {e}"),
56 }
57 }
58}
59
60impl std::error::Error for ParseJsonError {
61 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
62 match self {
63 Self::NoJsonFound => None,
64 Self::Deserialize(e) => Some(e),
65 }
66 }
67}
68
69pub struct JsonExtractor<T>(std::marker::PhantomData<T>);
71
72impl<T: DeserializeOwned> JsonExtractor<T> {
73 pub fn parse(text: &str) -> Result<T, ParseJsonError> {
75 parse_json(text)
76 }
77}
78
79pub fn extract_xml_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
92 let open = format!("<{tag}>");
93 let close = format!("</{tag}>");
94 let start = text.find(open.as_str())? + open.len();
95 let end = text[start..].find(close.as_str())?;
96 Some(text[start..start + end].trim())
97}
98
99fn extract_fenced(text: &str, lang: &str) -> Option<String> {
100 let opener = if lang.is_empty() {
101 "```".to_string()
102 } else {
103 format!("```{}", lang)
104 };
105
106 let start = text.find(&opener)?;
107 let after_open = start + opener.len();
108
109 let body_start = text[after_open..]
111 .find('\n')
112 .map_or(after_open, |i| after_open + i + 1);
113
114 let closer = text[body_start..].find("```")?;
116 let body = text[body_start..body_start + closer].trim();
117
118 if body.is_empty() {
119 return None;
120 }
121
122 Some(body.to_string())
123}
124
125fn looks_like_json(s: &str) -> bool {
126 let trimmed = s.trim();
127 trimmed.starts_with('{') || trimmed.starts_with('[')
128}
129
130fn find_balanced_json(text: &str) -> Option<String> {
131 for (i, b) in text.bytes().enumerate() {
132 if b == b'{' || b == b'[' {
133 let open = b;
134 let close = if open == b'{' { b'}' } else { b']' };
135 if let Some(end) = find_balanced_end(text.as_bytes(), i, open, close) {
136 return Some(text[i..=end].to_string());
137 }
138 }
139 }
140 None
141}
142
143fn find_balanced_end(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize> {
144 let mut depth = 0i32;
145 let mut in_string = false;
146 let mut escape = false;
147
148 for (i, &b) in bytes[start..].iter().enumerate() {
149 if escape {
150 escape = false;
151 continue;
152 }
153 if b == b'\\' && in_string {
154 escape = true;
155 continue;
156 }
157 if b == b'"' {
158 in_string = !in_string;
159 continue;
160 }
161 if in_string {
162 continue;
163 }
164 if b == open {
165 depth += 1;
166 } else if b == close {
167 depth -= 1;
168 if depth == 0 {
169 return Some(start + i);
170 }
171 }
172 }
173 None
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use serde::Deserialize;
180
181 #[derive(Debug, Deserialize, PartialEq)]
182 struct TestOutput {
183 name: String,
184 value: i32,
185 }
186
187 #[test]
188 fn extract_from_json_fence() {
189 let input = "Here is the result:\n```json\n{\"name\":\"test\",\"value\":42}\n```\nDone.";
190 let json = extract_json(input).unwrap();
191 let parsed: TestOutput = serde_json::from_str(&json).unwrap();
192 assert_eq!(
193 parsed,
194 TestOutput {
195 name: "test".into(),
196 value: 42
197 }
198 );
199 }
200
201 #[test]
202 fn extract_from_generic_fence() {
203 let input = "```\n{\"name\":\"x\",\"value\":1}\n```";
204 let json = extract_json(input).unwrap();
205 assert!(json.contains("\"name\""));
206 }
207
208 #[test]
209 fn extract_raw_json_object() {
210 let input = "The answer is {\"name\":\"raw\",\"value\":7} as shown.";
211 let json = extract_json(input).unwrap();
212 let parsed: TestOutput = serde_json::from_str(&json).unwrap();
213 assert_eq!(
214 parsed,
215 TestOutput {
216 name: "raw".into(),
217 value: 7
218 }
219 );
220 }
221
222 #[test]
223 fn extract_raw_json_array() {
224 let input = "Items: [1, 2, 3]";
225 let json = extract_json(input).unwrap();
226 assert_eq!(json, "[1, 2, 3]");
227 }
228
229 #[test]
230 fn no_json_returns_none() {
231 assert!(extract_json("no json here").is_none());
232 }
233
234 #[test]
235 fn parse_json_works() {
236 let input = "```json\n{\"name\":\"ok\",\"value\":99}\n```";
237 let result: TestOutput = parse_json(input).unwrap();
238 assert_eq!(result.value, 99);
239 }
240
241 #[test]
242 fn parse_json_fails_gracefully() {
243 let result: Result<TestOutput, ParseJsonError> = parse_json("no json");
244 assert!(matches!(result, Err(ParseJsonError::NoJsonFound)));
245 }
246
247 #[test]
248 fn nested_json() {
249 let input = r#"{"outer": {"inner": [1,2]}}"#;
250 let json = extract_json(input).unwrap();
251 assert!(json.starts_with('{'));
252 assert!(json.ends_with('}'));
253 }
254
255 #[test]
256 fn escaped_quotes_in_string() {
257 let input = r#"{"name":"he said \"hello\"","value":0}"#;
258 let json = extract_json(input).unwrap();
259 assert!(json.contains("hello"));
260 }
261}