llm_output_parser/
choice.rs1use crate::error::{
8 ensure_input_within_limits, record_extracted_span, ParseError, ParseOptions, ParseTrace,
9};
10use crate::extract::preprocess_opts;
11
12pub 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
38pub 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 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 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
111fn 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 let boundary_before = abs_pos == 0 || !h_bytes[abs_pos - 1].is_ascii_alphanumeric();
124
125 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}