Skip to main content

af_llm/
json.rs

1//! Helpers for coaxing structured JSON out of free-form LLM text.
2//!
3//! Port of `agent_core/llm/utils.py::parse_json_response`, generalized: instead
4//! of always returning a `dict`, it deserializes into any `serde` type so the
5//! caller gets a validated struct (the strongly-typed equivalent of "parse,
6//! don't validate").
7
8use serde::de::DeserializeOwned;
9
10/// Strip a Markdown code fence (```json … ``` or ``` … ```) if present, and
11/// trim surrounding whitespace. Returns the inner payload.
12pub fn strip_code_fence(raw: &str) -> &str {
13    let trimmed = raw.trim();
14    let Some(after_open) = trimmed.strip_prefix("```") else {
15        return trimmed;
16    };
17
18    // Drop an optional language tag on the opening fence line ("json", "JSON").
19    let after_lang = match after_open.find('\n') {
20        Some(nl) => &after_open[nl + 1..],
21        None => after_open,
22    };
23
24    // Drop the closing fence.
25    after_lang
26        .trim_end()
27        .strip_suffix("```")
28        .unwrap_or(after_lang)
29        .trim()
30}
31
32/// Extract and deserialize a JSON value from an LLM text response, tolerating
33/// Markdown code fences.
34///
35/// Returns `None` if `content` is empty or the payload is not valid JSON for
36/// the target type — same fail-soft contract as the Python original, but the
37/// success type is whatever you ask for.
38pub fn parse_json<T: DeserializeOwned>(content: &str) -> Option<T> {
39    let payload = strip_code_fence(content);
40    if payload.is_empty() {
41        return None;
42    }
43    match serde_json::from_str(payload) {
44        Ok(v) => Some(v),
45        Err(err) => {
46            tracing::warn!(target: "llm.json", error = %err, "parse_json_failed");
47            None
48        }
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use serde::Deserialize;
56
57    #[derive(Debug, Deserialize, PartialEq)]
58    struct Verdict {
59        ok: bool,
60        score: f64,
61    }
62
63    #[test]
64    fn parses_bare_json() {
65        let v: Verdict = parse_json(r#"{"ok": true, "score": 0.9}"#).unwrap();
66        assert_eq!(
67            v,
68            Verdict {
69                ok: true,
70                score: 0.9
71            }
72        );
73    }
74
75    #[test]
76    fn parses_fenced_json_with_lang_tag() {
77        let raw = "```json\n{\"ok\": false, \"score\": 0.1}\n```";
78        let v: Verdict = parse_json(raw).unwrap();
79        assert_eq!(
80            v,
81            Verdict {
82                ok: false,
83                score: 0.1
84            }
85        );
86    }
87
88    #[test]
89    fn parses_fenced_json_without_lang_tag() {
90        let raw = "```\n{\"ok\": true, \"score\": 1.0}\n```";
91        let v: Verdict = parse_json(raw).unwrap();
92        assert!(v.ok);
93    }
94
95    #[test]
96    fn returns_none_on_garbage() {
97        assert!(parse_json::<Verdict>("not json at all").is_none());
98        assert!(parse_json::<Verdict>("").is_none());
99    }
100}