Skip to main content

llm_output_parser/
number.rs

1//! Numeric value extraction from LLM responses.
2//!
3//! Provides [`parse_number`] for extracting a numeric value and
4//! [`parse_number_in_range`] for bounded extraction, handling common LLM
5//! output patterns like "Score: 8.5", "8/10", and prose-wrapped numbers.
6
7use std::str::FromStr;
8
9use crate::error::{
10    ensure_input_within_limits, record_extracted_span, ParseError, ParseOptions, ParseTrace,
11};
12use crate::extract::preprocess_opts;
13
14/// Extract a numeric value from an LLM response.
15///
16/// Handles common patterns:
17/// - Direct number: `"8.5"`
18/// - Score format: `"8.5/10"`, `"8/10"`
19/// - In prose: `"I'd rate it 8.5 out of 10"`
20/// - Labeled: `"Score: 8.5"`, `"Rating: 8"`
21/// - With think block: `"<think>considering...</think>8.5"`
22///
23/// # Examples
24///
25/// ```
26/// use llm_output_parser::parse_number;
27///
28/// let score: f64 = parse_number("Score: 8.5").unwrap();
29/// assert!((score - 8.5).abs() < f64::EPSILON);
30/// ```
31pub fn parse_number<T: FromStr>(response: &str) -> Result<T, ParseError> {
32    let opts = ParseOptions::default();
33    let (value, _trace) = parse_number_with_trace(response, &opts)?;
34    Ok(value)
35}
36
37/// Extract a numeric value from an LLM response with diagnostic trace.
38pub fn parse_number_with_trace<T: FromStr>(
39    response: &str,
40    opts: &ParseOptions,
41) -> Result<(T, ParseTrace), ParseError> {
42    ensure_input_within_limits(response, opts)?;
43
44    let cleaned = preprocess_opts(response, opts.strip_think_tags);
45    let mut trace = ParseTrace::default();
46
47    if cleaned.is_empty() {
48        return Err(ParseError::EmptyResponse);
49    }
50
51    trace.strategies_tried.push("direct_parse");
52    if let Ok(val) = cleaned.parse::<T>() {
53        trace.extracted_span = Some((0, cleaned.len()));
54        return Ok((val, trace));
55    }
56
57    let lower = cleaned.to_lowercase();
58
59    trace.strategies_tried.push("labeled_number");
60    for label in ["score:", "rating:", "result:"] {
61        if let Some(pos) = lower.find(label) {
62            let after = &cleaned[pos + label.len()..];
63            let nums = find_all_numbers(after);
64            if let Some(first) = nums.first() {
65                if let Ok(val) = first.parse::<T>() {
66                    record_extracted_span(&mut trace, &cleaned, first);
67                    return Ok((val, trace));
68                }
69            }
70        }
71    }
72
73    trace.strategies_tried.push("fraction_number");
74    if let Some(num) = extract_fraction::<T>(&cleaned) {
75        if let Some(fraction_text) = cleaned
76            .split_whitespace()
77            .find(|part| part.contains('/'))
78            .map(str::trim)
79        {
80            record_extracted_span(&mut trace, &cleaned, fraction_text);
81        }
82        return Ok((num, trace));
83    }
84
85    trace.strategies_tried.push("scan_all_numbers");
86    let numbers = find_all_numbers(&cleaned);
87    // Try from the end
88    for candidate in numbers.iter().rev() {
89        if let Ok(val) = candidate.parse::<T>() {
90            record_extracted_span(&mut trace, &cleaned, candidate);
91            return Ok((val, trace));
92        }
93    }
94
95    Err(ParseError::NoNumber)
96}
97
98/// Extract a number and verify it falls within `[min, max]` inclusive.
99///
100/// # Examples
101///
102/// ```
103/// use llm_output_parser::parse_number_in_range;
104///
105/// let score: i32 = parse_number_in_range("8", 1, 10).unwrap();
106/// assert_eq!(score, 8);
107/// ```
108pub fn parse_number_in_range<T: FromStr + PartialOrd + std::fmt::Display>(
109    response: &str,
110    min: T,
111    max: T,
112) -> Result<T, ParseError> {
113    let opts = ParseOptions::default();
114    let (val, _trace) = parse_number_in_range_with_trace(response, min, max, &opts)?;
115    Ok(val)
116}
117
118/// Extract a bounded numeric value with diagnostic trace.
119pub fn parse_number_in_range_with_trace<T: FromStr + PartialOrd + std::fmt::Display>(
120    response: &str,
121    min: T,
122    max: T,
123    opts: &ParseOptions,
124) -> Result<(T, ParseTrace), ParseError> {
125    let (val, trace) = parse_number_with_trace::<T>(response, opts)?;
126    if val < min || val > max {
127        return Err(ParseError::NoNumber);
128    }
129    Ok((val, trace))
130}
131
132/// Extract the numerator from a fraction pattern like "8/10" or "8.5/10".
133fn extract_fraction<T: FromStr>(text: &str) -> Option<T> {
134    let chars: Vec<char> = text.chars().collect();
135    let len = chars.len();
136
137    for i in 0..len {
138        if chars[i] == '/' {
139            // Look backwards for the numerator
140            let mut num_start = i;
141            while num_start > 0 {
142                let c = chars[num_start - 1];
143                if c.is_ascii_digit() || c == '.' || c == '-' {
144                    num_start -= 1;
145                } else {
146                    break;
147                }
148            }
149            if num_start < i {
150                let num_str: String = chars[num_start..i].iter().collect();
151                if let Ok(val) = num_str.parse::<T>() {
152                    return Some(val);
153                }
154            }
155        }
156    }
157    None
158}
159
160/// Find all number-like substrings in text (digits, optional decimal, optional leading minus).
161fn find_all_numbers(text: &str) -> Vec<String> {
162    let mut numbers = Vec::new();
163    let chars: Vec<char> = text.chars().collect();
164    let len = chars.len();
165    let mut i = 0;
166
167    while i < len {
168        // Check for start of a number
169        let is_negative = chars[i] == '-' && i + 1 < len && chars[i + 1].is_ascii_digit();
170        let is_digit = chars[i].is_ascii_digit();
171
172        if is_digit || is_negative {
173            let start = i;
174            if is_negative {
175                i += 1;
176            }
177            // Consume digits
178            while i < len && chars[i].is_ascii_digit() {
179                i += 1;
180            }
181            // Optional decimal part
182            if i < len && chars[i] == '.' && i + 1 < len && chars[i + 1].is_ascii_digit() {
183                i += 1; // skip dot
184                while i < len && chars[i].is_ascii_digit() {
185                    i += 1;
186                }
187            }
188            let num_str: String = chars[start..i].iter().collect();
189            numbers.push(num_str);
190            continue;
191        }
192        i += 1;
193    }
194
195    numbers
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn integer() {
204        let result: i32 = parse_number("8").unwrap();
205        assert_eq!(result, 8);
206    }
207
208    #[test]
209    fn float() {
210        let result: f64 = parse_number("8.5").unwrap();
211        assert!((result - 8.5).abs() < f64::EPSILON);
212    }
213
214    #[test]
215    fn fraction() {
216        let result: i32 = parse_number("8/10").unwrap();
217        assert_eq!(result, 8);
218    }
219
220    #[test]
221    fn in_prose() {
222        let result: f64 = parse_number("I'd give it a 7.5").unwrap();
223        assert!((result - 7.5).abs() < f64::EPSILON);
224    }
225
226    #[test]
227    fn labeled() {
228        let result: i32 = parse_number("Score: 9").unwrap();
229        assert_eq!(result, 9);
230    }
231
232    #[test]
233    fn with_think() {
234        let result: f64 = parse_number("<think>considering</think>8.5").unwrap();
235        assert!((result - 8.5).abs() < f64::EPSILON);
236    }
237
238    #[test]
239    fn negative() {
240        let result: i32 = parse_number("-3").unwrap();
241        assert_eq!(result, -3);
242    }
243
244    #[test]
245    fn range_pass() {
246        let result: i32 = parse_number_in_range("8", 1, 10).unwrap();
247        assert_eq!(result, 8);
248    }
249
250    #[test]
251    fn range_fail() {
252        let result: Result<i32, _> = parse_number_in_range("15", 1, 10);
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn no_number() {
258        let result: Result<i32, _> = parse_number("great work");
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn multiple_numbers_labeled() {
264        let result: i32 = parse_number("Page 3 of 5, Score: 8").unwrap();
265        assert_eq!(result, 8);
266    }
267
268    #[test]
269    fn with_trace_records_fraction_strategy() {
270        let opts = ParseOptions::default();
271        let (result, trace): (i32, ParseTrace) = parse_number_with_trace("8/10", &opts).unwrap();
272        assert_eq!(result, 8);
273        assert!(trace.strategies_tried.contains(&"fraction_number"));
274        assert!(trace.extracted_span.is_some());
275    }
276
277    #[test]
278    fn in_range_with_trace_rejects_oversized_input() {
279        let opts = ParseOptions {
280            max_input_bytes: 4,
281            ..ParseOptions::default()
282        };
283        let err = parse_number_in_range_with_trace::<i32>("Score: 8", 1, 10, &opts).unwrap_err();
284        assert_eq!(err.kind(), "too_large");
285    }
286}