Skip to main content

llm_output_parser/
choice.rs

1//! Choice extraction from LLM responses.
2//!
3//! Provides [`parse_choice`] for extracting a single choice from a set of
4//! valid options, handling common LLM formatting patterns like bold, quotes,
5//! and prose wrapping.
6
7use crate::error::{
8    ensure_input_within_limits, record_extracted_span, ParseError, ParseOptions, ParseTrace,
9};
10use crate::extract::preprocess_opts;
11
12/// Extract a single choice from a set of valid options.
13///
14/// Handles common LLM response patterns:
15/// - Direct match: `"positive"`
16/// - Bold: `"**positive**"`
17/// - Quoted: `"'positive'"` or `"\"positive\""`
18/// - In prose: `"I would classify this as positive because..."`
19/// - Parenthesized: `"(positive)"`
20///
21/// Matching is case-insensitive. If multiple valid choices appear,
22/// returns the first one found in the text.
23///
24/// # Examples
25///
26/// ```
27/// use llm_output_parser::parse_choice;
28///
29/// let result = parse_choice("I'd classify this as positive", &["positive", "negative"]).unwrap();
30/// assert_eq!(result, "positive");
31/// ```
32pub fn parse_choice<'a>(response: &str, valid_choices: &[&'a str]) -> Result<&'a str, ParseError> {
33    let opts = ParseOptions::default();
34    let (choice, _trace) = parse_choice_with_trace(response, valid_choices, &opts)?;
35    Ok(choice)
36}
37
38/// Extract a single choice from a set of valid options with diagnostic trace.
39pub fn parse_choice_with_trace<'a>(
40    response: &str,
41    valid_choices: &[&'a str],
42    opts: &ParseOptions,
43) -> Result<(&'a str, ParseTrace), ParseError> {
44    ensure_input_within_limits(response, opts)?;
45
46    let cleaned = preprocess_opts(response, opts.strip_think_tags);
47    let mut trace = ParseTrace::default();
48
49    if cleaned.is_empty() {
50        return Err(ParseError::EmptyResponse);
51    }
52
53    let lower = cleaned.to_lowercase();
54
55    // Strip common wrappers for exact matching
56    let stripped = lower
57        .trim_matches(|c: char| c == '.' || c == '!' || c == ',' || c.is_whitespace())
58        .trim_start_matches("**")
59        .trim_end_matches("**")
60        .trim_matches('"')
61        .trim_matches('\'')
62        .trim_matches('(')
63        .trim_matches(')')
64        .trim();
65
66    trace.strategies_tried.push("exact_match");
67    for &choice in valid_choices {
68        if stripped.eq_ignore_ascii_case(choice) {
69            record_extracted_span(&mut trace, &cleaned, stripped);
70            return Ok((choice, trace));
71        }
72    }
73
74    trace.strategies_tried.push("prefix_match");
75    for &choice in valid_choices {
76        let choice_lower = choice.to_lowercase();
77        if stripped.starts_with(&choice_lower) {
78            // Check word boundary after the choice
79            let after = stripped.len().min(choice_lower.len());
80            if after == stripped.len() || !stripped.as_bytes()[after].is_ascii_alphanumeric() {
81                record_extracted_span(&mut trace, &cleaned, choice);
82                return Ok((choice, trace));
83            }
84        }
85    }
86
87    trace.strategies_tried.push("word_boundary_search");
88    let mut best: Option<(&'a str, usize)> = None;
89
90    for &choice in valid_choices {
91        let choice_lower = choice.to_lowercase();
92        if let Some(pos) = find_word_boundary_match(&lower, &choice_lower) {
93            match best {
94                None => best = Some((choice, pos)),
95                Some((_, best_pos)) if pos < best_pos => best = Some((choice, pos)),
96                _ => {}
97            }
98        }
99    }
100
101    if let Some((choice, pos)) = best {
102        trace.extracted_span = Some((pos, pos + choice.len()));
103        return Ok((choice, trace));
104    }
105
106    Err(ParseError::NoMatchingChoice {
107        valid: valid_choices.iter().map(|s| s.to_string()).collect(),
108    })
109}
110
111/// Find a word-boundary match of `needle` in `haystack`.
112/// Returns the position of the first match, or None.
113fn find_word_boundary_match(haystack: &str, needle: &str) -> Option<usize> {
114    let h_bytes = haystack.as_bytes();
115    let n_len = needle.len();
116    let mut search_from = 0;
117
118    while let Some(pos) = haystack[search_from..].find(needle) {
119        let abs_pos = search_from + pos;
120        let end_pos = abs_pos + n_len;
121
122        // Check boundary before
123        let boundary_before = abs_pos == 0 || !h_bytes[abs_pos - 1].is_ascii_alphanumeric();
124
125        // Check boundary after
126        let boundary_after = end_pos >= haystack.len() || !h_bytes[end_pos].is_ascii_alphanumeric();
127
128        if boundary_before && boundary_after {
129            return Some(abs_pos);
130        }
131
132        search_from = abs_pos + 1;
133    }
134
135    None
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn exact_match() {
144        let result = parse_choice("positive", &["positive", "negative"]).unwrap();
145        assert_eq!(result, "positive");
146    }
147
148    #[test]
149    fn with_period() {
150        let result = parse_choice("positive.", &["positive", "negative"]).unwrap();
151        assert_eq!(result, "positive");
152    }
153
154    #[test]
155    fn bold() {
156        let result = parse_choice("**positive**", &["positive", "negative"]).unwrap();
157        assert_eq!(result, "positive");
158    }
159
160    #[test]
161    fn quoted() {
162        let result = parse_choice("\"positive\"", &["positive", "negative"]).unwrap();
163        assert_eq!(result, "positive");
164    }
165
166    #[test]
167    fn in_prose() {
168        let result =
169            parse_choice("I'd classify this as positive", &["positive", "negative"]).unwrap();
170        assert_eq!(result, "positive");
171    }
172
173    #[test]
174    fn case_insensitive() {
175        let result = parse_choice("POSITIVE", &["positive", "negative"]).unwrap();
176        assert_eq!(result, "positive");
177    }
178
179    #[test]
180    fn first_wins() {
181        let result =
182            parse_choice("positive and negative aspects", &["positive", "negative"]).unwrap();
183        assert_eq!(result, "positive");
184    }
185
186    #[test]
187    fn with_think() {
188        let result = parse_choice("<think>hmm</think>negative", &["positive", "negative"]).unwrap();
189        assert_eq!(result, "negative");
190    }
191
192    #[test]
193    fn no_match() {
194        let result = parse_choice("maybe", &["positive", "negative"]);
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn no_substring() {
200        let result = parse_choice("unpositive", &["positive"]);
201        assert!(result.is_err());
202    }
203
204    #[test]
205    fn with_trace_records_strategy() {
206        let opts = ParseOptions::default();
207        let (choice, trace) =
208            parse_choice_with_trace("I would approve this.", &["approve", "reject"], &opts)
209                .unwrap();
210        assert_eq!(choice, "approve");
211        assert!(trace.strategies_tried.contains(&"word_boundary_search"));
212        assert!(trace.extracted_span.is_some());
213    }
214
215    #[test]
216    fn with_trace_rejects_oversized_input() {
217        let opts = ParseOptions {
218            max_input_bytes: 8,
219            ..ParseOptions::default()
220        };
221        let err = parse_choice_with_trace("definitely approve", &["approve"], &opts).unwrap_err();
222        assert_eq!(err.kind(), "too_large");
223    }
224}