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: ®ex::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}