Skip to main content

spar/
jsonx.rs

1//! Getting structured data back out of a model, and hashing it stably.
2
3use serde_json::Value;
4use sha2::{Digest, Sha256};
5use std::sync::LazyLock;
6
7use regex::Regex;
8
9use crate::error::{Result, SparError};
10
11static FENCE: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(r"(?s)```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```").expect("fence pattern")
13});
14
15/// Pull the last JSON value out of a model response.
16///
17/// Models wrap JSON in prose or fences despite explicit instructions not to,
18/// and some emit a draft before the real answer, so the *last* well formed
19/// value wins: fenced blocks first, then brace matching backwards from the end.
20pub fn extract_json(text: &str) -> Result<Value> {
21    if text.trim().is_empty() {
22        return Err(SparError::new("empty response, expected JSON"));
23    }
24    candidates(text)
25        .into_iter()
26        .next()
27        .ok_or_else(|| SparError::new(format!("no JSON found in response:\n{}", head(text, 800))))
28}
29
30/// Every JSON value plausibly present in a model response, most likely first.
31///
32/// Fenced blocks come first because a model that fences its answer means it,
33/// then whole objects matched backwards from the end.
34pub fn candidates(text: &str) -> Vec<Value> {
35    let mut out = Vec::new();
36    let mut push = |value: Value| {
37        if !out.contains(&value) {
38            out.push(value);
39        }
40    };
41
42    let fenced: Vec<&str> = FENCE
43        .captures_iter(text)
44        .filter_map(|c| c.get(1).map(|m| m.as_str()))
45        .collect();
46    for blob in fenced.iter().rev() {
47        if let Ok(value) = serde_json::from_str::<Value>(blob) {
48            push(value);
49        }
50    }
51
52    let bytes = text.as_bytes();
53    for (opener, closer) in [(b'{', b'}'), (b'[', b']')] {
54        let mut end = rfind_byte(bytes, closer, bytes.len());
55        while let Some(e) = end {
56            let mut depth = 0i32;
57            let mut start = None;
58            for i in (0..=e).rev() {
59                if bytes[i] == closer {
60                    depth += 1;
61                } else if bytes[i] == opener {
62                    depth -= 1;
63                    if depth == 0 {
64                        start = Some(i);
65                        break;
66                    }
67                }
68            }
69            if let Some(s) = start {
70                if let Ok(value) = serde_json::from_str::<Value>(&text[s..=e]) {
71                    push(value);
72                }
73            }
74            end = rfind_byte(bytes, closer, e);
75        }
76    }
77    out
78}
79
80/// Whether a response looks cut off rather than merely malformed.
81///
82/// A model with an output limit stops mid-object on a long answer. The braces
83/// it opened outnumber the ones it closed, and every complete object left is
84/// something nested inside the one it was building.
85pub fn looks_truncated(text: &str) -> bool {
86    let mut opened = 0i64;
87    let mut in_string = false;
88    let mut escaped = false;
89    for c in text.chars() {
90        if escaped {
91            escaped = false;
92            continue;
93        }
94        match c {
95            '\\' if in_string => escaped = true,
96            '"' => in_string = !in_string,
97            '{' if !in_string => opened += 1,
98            '}' if !in_string => opened -= 1,
99            _ => {}
100        }
101    }
102    opened > 0
103}
104
105/// Parse a model response straight into a typed value.
106///
107/// Tries every candidate rather than only the last one found. A review cut off
108/// before its outer object closed used to yield the last *nested* finding,
109/// which parsed as JSON perfectly well and then failed to be a review, and the
110/// error blamed the shape rather than the truncation.
111pub fn extract_into<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
112    let found = candidates(text);
113    if found.is_empty() {
114        return Err(SparError::new(if looks_truncated(text) {
115            format!(
116                "the response was cut off before any complete JSON:\n{}",
117                head(text, 400)
118            )
119        } else {
120            format!("no JSON found in response:\n{}", head(text, 800))
121        }));
122    }
123
124    let mut last_error = None;
125    for value in &found {
126        match serde_json::from_value::<T>(value.clone()) {
127            Ok(parsed) => return Ok(parsed),
128            Err(e) => last_error = Some((e, value)),
129        }
130    }
131
132    let (error, value) = last_error.expect("non-empty");
133    if looks_truncated(text) {
134        return Err(SparError::new(format!(
135            "the response was cut off before the answer was complete, so only fragments of it \
136             parsed ({error}). Ask for less in one go, or give this agent a CLI flag for native \
137             structured output."
138        )));
139    }
140    Err(SparError::new(format!(
141        "response did not match the expected shape ({error}).\nGot: {}",
142        head(&value.to_string(), 600)
143    )))
144}
145
146fn rfind_byte(haystack: &[u8], needle: u8, before: usize) -> Option<usize> {
147    haystack[..before.min(haystack.len())]
148        .iter()
149        .rposition(|b| *b == needle)
150}
151
152fn head(text: &str, max: usize) -> String {
153    text.chars().take(max).collect()
154}
155
156/// A stable identity for a review finding, so a refutation survives across
157/// rounds even when the reviewer rewords the point.
158///
159/// Wording noise, punctuation, and case are all discarded; the file is not,
160/// because the same complaint about two different files is two complaints.
161pub fn finding_key(title: &str, file: &str) -> String {
162    let basis: String = format!("{} {}", title.trim(), file.trim())
163        .to_lowercase()
164        .chars()
165        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '/' | '.' | '_' | '-'))
166        .collect();
167    let basis = basis.split_whitespace().collect::<Vec<_>>().join(" ");
168
169    let digest = Sha256::digest(basis.as_bytes());
170    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
171    hex[..12].to_string()
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn bare_object() {
180        assert_eq!(
181            serde_json::json!({"a": 1}),
182            extract_json(r#"{"a": 1}"#).unwrap()
183        );
184    }
185
186    #[test]
187    fn fenced_block() {
188        let out = extract_json("here you go:\n```json\n{\"a\": 1}\n```\n").unwrap();
189        assert_eq!(serde_json::json!({"a": 1}), out);
190    }
191
192    #[test]
193    fn trailing_prose() {
194        let out = extract_json("Thoughts...\n{\"verdict\": \"approve\"}\nDone.").unwrap();
195        assert_eq!(serde_json::json!({"verdict": "approve"}), out);
196    }
197
198    #[test]
199    fn picks_the_last_fenced_block() {
200        let text = "```json\n{\"n\": 1}\n```\nrevised:\n```json\n{\"n\": 2}\n```";
201        assert_eq!(serde_json::json!({"n": 2}), extract_json(text).unwrap());
202    }
203
204    #[test]
205    fn nested_braces() {
206        let payload = r#"{"findings": [{"severity": "nit", "d": {"x": [1, 2]}}]}"#;
207        let out = extract_json(&format!("blah {payload} blah")).unwrap();
208        assert_eq!(1, out["findings"].as_array().unwrap().len());
209    }
210
211    #[test]
212    fn top_level_array() {
213        let out = extract_json("result: [1, 2, 3]").unwrap();
214        assert_eq!(3, out.as_array().unwrap().len());
215    }
216
217    #[test]
218    fn multibyte_prose_around_the_payload_does_not_panic() {
219        let out = extract_json("\u{1f600}\u{1f600} {\"a\": 1} \u{1f600}").unwrap();
220        assert_eq!(serde_json::json!({"a": 1}), out);
221    }
222
223    #[test]
224    fn raises_when_there_is_none() {
225        assert!(extract_json("no json here at all").is_err());
226    }
227
228    #[test]
229    fn raises_on_empty() {
230        assert!(extract_json("   ").is_err());
231    }
232
233    #[test]
234    fn malformed_trailing_object_falls_back_to_an_earlier_one() {
235        let text = "{\"good\": true}\nthen: {\"bad\": ,}";
236        assert_eq!(
237            serde_json::json!({"good": true}),
238            extract_json(text).unwrap()
239        );
240    }
241
242    // -- finding_key -----------------------------------------------------
243
244    #[test]
245    fn key_is_stable_across_wording_noise() {
246        assert_eq!(
247            finding_key("Unbounded loop!", "src/x.rs"),
248            finding_key("unbounded loop", "src/x.rs")
249        );
250    }
251
252    #[test]
253    fn key_differs_by_file() {
254        assert_ne!(finding_key("t", "a.rs"), finding_key("t", "b.rs"));
255    }
256
257    #[test]
258    fn key_is_case_insensitive_in_the_path_too() {
259        assert_eq!(
260            finding_key("t", "src/Main.rs"),
261            finding_key("t", "src/main.rs")
262        );
263    }
264
265    #[test]
266    fn key_is_stable_across_whitespace() {
267        assert_eq!(finding_key("a  b", "x.rs"), finding_key(" a b ", "x.rs"));
268    }
269
270    #[test]
271    fn key_is_twelve_hex_characters() {
272        let key = finding_key("anything", "file.rs");
273        assert_eq!(12, key.len());
274        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
275    }
276}
277
278#[cfg(test)]
279mod truncation_tests {
280    use super::*;
281    use serde::Deserialize;
282
283    #[derive(Debug, Deserialize)]
284    struct Review {
285        verdict: String,
286        findings: Vec<Finding>,
287    }
288    #[derive(Debug, Deserialize)]
289    struct Finding {
290        title: String,
291    }
292
293    /// What actually happened on a real pull request. A long review hit the
294    /// model's output limit and stopped before its outer object closed, so the
295    /// last complete JSON in the response was a nested finding. It parsed, it
296    /// was not a review, and the error blamed the shape.
297    const TRUNCATED: &str = r#"Here is my review.
298{"verdict":"changes_requested","next_action":"hand_back","summary":"Two problems.",
299 "findings":[
300   {"severity":"blocking","title":"First","detail":"one","file":"a.ts","in_scope":true},
301   {"severity":"non-blocking","title":"Second","detail":"numbers unnamed keys by position"#;
302
303    #[test]
304    fn a_truncated_review_is_reported_as_truncated_not_as_the_wrong_shape() {
305        let err = extract_into::<Review>(TRUNCATED).unwrap_err().to_string();
306        assert!(err.contains("cut off"), "{err}");
307        assert!(!err.contains("did not match the expected shape"), "{err}");
308    }
309
310    #[test]
311    fn truncation_is_detected_from_the_unclosed_braces() {
312        assert!(looks_truncated(TRUNCATED));
313        assert!(!looks_truncated(r#"{"a":1}"#));
314        // A brace inside a string is not an open brace.
315        assert!(!looks_truncated(r#"{"a":"a { in a string"}"#));
316        assert!(!looks_truncated(r#"{"a":"an escaped \" quote { here"}"#));
317    }
318
319    /// The fix that matters: the right object is found even when it is not the
320    /// last one in the response.
321    #[test]
322    fn the_review_is_found_even_with_nested_objects_after_it() {
323        let text = r#"Thinking out loud first.
324{"verdict":"approve","next_action":"merge","summary":"Fine.","findings":[{"severity":"nit","title":"Wording","detail":"d","file":"a.ts","in_scope":true}]}
325And here is a stray object afterwards: {"title":"not the review"}"#;
326        let review: Review = extract_into(text).unwrap();
327        assert_eq!("approve", review.verdict);
328        assert_eq!(1, review.findings.len());
329        assert_eq!("Wording", review.findings[0].title);
330    }
331
332    #[test]
333    fn candidates_are_offered_most_likely_first() {
334        let text = "```json\n{\"verdict\":\"approve\",\"findings\":[]}\n```\ntrailing {\"x\":1}";
335        let review: Review = extract_into(text).unwrap();
336        assert_eq!("approve", review.verdict);
337    }
338
339    #[test]
340    fn a_genuinely_wrong_shape_still_says_so() {
341        let err = extract_into::<Review>(r#"{"colour":"blue"}"#)
342            .unwrap_err()
343            .to_string();
344        assert!(err.contains("did not match the expected shape"), "{err}");
345        assert!(!err.contains("cut off"), "{err}");
346    }
347
348    #[test]
349    fn nothing_parseable_is_still_reported_plainly() {
350        let err = extract_into::<Review>("no json at all")
351            .unwrap_err()
352            .to_string();
353        assert!(err.contains("no JSON found"), "{err}");
354    }
355}