Skip to main content

netget/llm/
reference_parser.rs

1//! XML Reference Parser for Large Content Blocks
2//!
3//! Allows LLMs to write large text blocks (scripts, configs, etc.) outside JSON
4//! using simple XML-style tags, avoiding JSON string escaping issues.
5//!
6//! # Format
7//!
8//! ```text
9//! {"actions": [{"code": "<script001>"}]}
10//!
11//! <script001>
12//! import json
13//! # No escaping needed!
14//! </script001>
15//! ```
16//!
17//! Supports both standard XML (`</tag>`) and simplified (`<tag>`) closing tags.
18//! Tags can appear before, after, or mixed with JSON.
19
20use anyhow::Result;
21use once_cell::sync::Lazy;
22use regex::Regex;
23use std::collections::HashMap;
24use tracing::{debug, trace};
25
26static REFERENCE_TAG: Lazy<Regex> = Lazy::new(|| {
27    Regex::new(r"<([a-zA-Z]+[0-9]+[a-zA-Z0-9_]*)>").expect("valid reference tag pattern")
28});
29
30/// Extract XML-style references from LLM response
31///
32/// Extracts content from tags like `<script001>content</script001>` or `<script001>content<script001>`
33/// and returns the cleaned response (with tags removed) plus a map of tag_name -> content.
34///
35/// # Arguments
36/// * `text` - The full LLM response text
37///
38/// # Returns
39/// * Tuple of (cleaned_text, references_map)
40///   - cleaned_text: Response with all XML blocks removed (leaving just JSON)
41///   - references_map: HashMap mapping tag names to their content
42pub fn extract_references(text: &str) -> Result<(String, HashMap<String, String>)> {
43    let mut refs = HashMap::new();
44    let mut cleaned = text.to_string();
45
46    // Find both opening <tag> and closing </tag> or <tag> patterns
47    // IMPORTANT: Only match tags that contain at least one digit to avoid matching
48    // HTML tags like <body>, <html>, <div> in response content. This feature is
49    // designed for tags like <script001>, <config1>, etc.
50    let opening_regex = &*REFERENCE_TAG;
51
52    // Collect blocks to extract and remove
53    let mut blocks_to_remove: Vec<(usize, usize)> = Vec::new();
54    let mut scan_position = 0;
55    let mut json_depth = 0usize;
56    let mut in_json_string = false;
57    let mut escaped = false;
58
59    // Find all opening tags
60    for opening_cap in opening_regex.captures_iter(text) {
61        let tag_name = &opening_cap[1];
62        let opening_match = opening_cap.get(0).unwrap();
63        let opening_start = opening_match.start();
64        let opening_end = opening_match.end();
65
66        // A reference body is opaque text, including any tag-like script/HTML content.
67        // Extracting nested ranges as independent references made the reverse removals
68        // overlap: removing the inner range invalidated the outer byte offsets and
69        // could panic on model-supplied text.
70        if blocks_to_remove
71            .last()
72            .is_some_and(|&(_, end)| opening_start < end)
73        {
74            continue;
75        }
76
77        // Skip if already extracted (duplicate tag)
78        if refs.contains_key(tag_name) {
79            continue;
80        }
81
82        // Track JSON strings across the original text, skipping opaque blocks once
83        // extracted. Checking only the preceding quote corrupted embedded placeholders
84        // such as `"prefix <script001> suffix"` and mishandled escaped quotes.
85        for &byte in &text.as_bytes()[scan_position..opening_start] {
86            if in_json_string {
87                if escaped {
88                    escaped = false;
89                } else if byte == b'\\' {
90                    escaped = true;
91                } else if byte == b'"' {
92                    in_json_string = false;
93                }
94            } else {
95                match byte {
96                    b'{' | b'[' => json_depth += 1,
97                    b'}' | b']' => json_depth = json_depth.saturating_sub(1),
98                    b'"' if json_depth > 0 => in_json_string = true,
99                    _ => {}
100                }
101            }
102        }
103        scan_position = opening_start;
104        if in_json_string {
105            continue;
106        }
107
108        // Look for matching closing tag (standard format: </tag>)
109        let closing_pattern = format!("</{}>", tag_name);
110        if let Some(closing_pos) = text[opening_end..].find(&closing_pattern) {
111            let abs_closing_pos = opening_end + closing_pos;
112            let closing_end = abs_closing_pos + closing_pattern.len();
113
114            // Extract content between tags
115            let content = text[opening_end..abs_closing_pos].trim().to_string();
116
117            debug!(
118                "Extracted reference (standard): <{}> ({} chars)",
119                tag_name,
120                content.len()
121            );
122            refs.insert(tag_name.to_string(), content);
123            blocks_to_remove.push((opening_start, closing_end));
124            scan_position = closing_end;
125            continue;
126        }
127
128        // If no standard closing found, try simplified format: <tag>
129        let simplified_pattern = format!("<{}>", tag_name);
130        if let Some(closing_pos) = text[opening_end..].find(&simplified_pattern) {
131            let abs_closing_pos = opening_end + closing_pos;
132            let closing_end = abs_closing_pos + simplified_pattern.len();
133
134            // Extract content between tags
135            let content = text[opening_end..abs_closing_pos].trim().to_string();
136
137            debug!(
138                "Extracted reference (simplified): <{}> ({} chars)",
139                tag_name,
140                content.len()
141            );
142            refs.insert(tag_name.to_string(), content);
143            blocks_to_remove.push((opening_start, closing_end));
144            scan_position = closing_end;
145        }
146    }
147
148    // Remove blocks in reverse order (to preserve indices)
149    blocks_to_remove.sort_by(|a, b| b.0.cmp(&a.0));
150    for (start, end) in blocks_to_remove {
151        cleaned.replace_range(start..end, "");
152    }
153
154    // Trim extra whitespace
155    cleaned = cleaned.trim().to_string();
156
157    trace!(
158        "Reference extraction complete: {} refs extracted, cleaned text {} chars",
159        refs.len(),
160        cleaned.len()
161    );
162
163    Ok((cleaned, refs))
164}
165
166/// Resolve XML reference placeholders in JSON string
167///
168/// Replaces placeholders like `"<script001>"` with the actual content from references map.
169///
170/// # Arguments
171/// * `json_str` - JSON string potentially containing reference placeholders
172/// * `refs` - Map of tag names to their content
173///
174/// # Returns
175/// * JSON string with all references resolved
176pub fn resolve_references(json_str: &str, refs: &HashMap<String, String>) -> String {
177    // Resolve only placeholders in the original input. Iterating the HashMap and
178    // repeatedly replacing in the result re-interpreted tags inside inserted scripts,
179    // making their contents depend on the map's randomized iteration order.
180    REFERENCE_TAG
181        .replace_all(json_str, |capture: &regex::Captures<'_>| {
182            refs.get(&capture[1])
183                .map(|content| escape_json_string(content))
184                .unwrap_or_else(|| capture[0].to_string())
185        })
186        .into_owned()
187}
188
189/// Escape string content for JSON
190///
191/// Handles common escape sequences: quotes, newlines, backslashes, etc.
192fn escape_json_string(s: &str) -> String {
193    // String serialization also escapes NUL and the other ASCII control characters;
194    // replacing only quotes/newlines produced invalid JSON for those payloads.
195    let quoted = serde_json::to_string(s).expect("serializing a string cannot fail");
196    quoted[1..quoted.len() - 1].to_string()
197}
198
199/// Check if a string contains XML reference placeholders
200/// Only matches tags containing at least one digit (like <script001>, <config1>)
201pub fn contains_references(text: &str) -> bool {
202    REFERENCE_TAG.is_match(text)
203}