Skip to main content

llm_output_parser/
list.rs

1//! String list extraction from LLM responses (generalized `parse_tags`).
2//!
3//! This module provides [`parse_string_list`] (the successor to `ollama-vision`'s
4//! `parse_tags`) and [`parse_string_list_raw`] for general-purpose list extraction.
5//!
6//! The [`parse_string_list_with_trace`] variant returns a [`ParseTrace`] alongside
7//! the result for observability.
8
9use std::collections::HashSet;
10
11use crate::error::{
12    ensure_input_within_limits, record_extracted_span, truncate, ParseError, ParseOptions,
13    ParseTrace,
14};
15use crate::extract::{extract_code_block, find_bracketed_limited, preprocess_opts};
16use crate::repair::try_repair_json;
17
18/// Parse an LLM response into a cleaned list of strings.
19///
20/// Cleaning: lowercase, trim, deduplicate, filter empties, filter >50 chars.
21/// This is the direct successor to `ollama-vision`'s `parse_tags`.
22///
23/// Strategies (in order):
24/// 1. Direct JSON array
25/// 2. JSON object with common list keys ("tags", "items", "results", "list")
26/// 3. Markdown code block -> JSON array/object
27/// 4. Bracket-matched JSON array
28/// 5. JSON repair on best candidate
29/// 6. Numbered/bulleted list extraction
30/// 7. Comma-separated fallback
31pub fn parse_string_list(response: &str) -> Result<Vec<String>, ParseError> {
32    let opts = ParseOptions::default();
33    let (items, _trace) = parse_string_list_with_trace(response, &opts)?;
34    Ok(items)
35}
36
37/// Parse into a list without tag-specific cleaning.
38///
39/// No forced lowercase, no length filter, no dedup.
40/// For general-purpose list extraction from LLM responses.
41pub fn parse_string_list_raw(response: &str) -> Result<Vec<String>, ParseError> {
42    let opts = ParseOptions::default();
43    ensure_input_within_limits(response, &opts)?;
44    let items = parse_string_list_inner_traced(response, &opts, &mut ParseTrace::default())?;
45    Ok(items
46        .into_iter()
47        .map(|s| s.trim().to_string())
48        .filter(|s| !s.is_empty())
49        .collect())
50}
51
52/// Parse an LLM response into a cleaned list of strings with diagnostic trace.
53///
54/// Identical to [`parse_string_list`] but accepts [`ParseOptions`] for safety
55/// limits and returns [`ParseTrace`] recording which strategies were attempted.
56///
57/// # Errors
58///
59/// Returns [`ParseError::TooLarge`] if input exceeds `opts.max_input_bytes`,
60/// [`ParseError::TooDeep`] if JSON nesting exceeds `opts.max_nesting_depth`.
61pub fn parse_string_list_with_trace(
62    response: &str,
63    opts: &ParseOptions,
64) -> Result<(Vec<String>, ParseTrace), ParseError> {
65    ensure_input_within_limits(response, opts)?;
66
67    let mut trace = ParseTrace::default();
68    let items = parse_string_list_inner_traced(response, opts, &mut trace)?;
69    Ok((clean_tags(items), trace))
70}
71
72/// Shared inner logic for list parsers (before cleaning), with tracing and limits.
73fn parse_string_list_inner_traced(
74    response: &str,
75    opts: &ParseOptions,
76    trace: &mut ParseTrace,
77) -> Result<Vec<String>, ParseError> {
78    let trimmed = response.trim();
79
80    if trimmed.is_empty() {
81        return Err(ParseError::EmptyResponse);
82    }
83
84    // Strategy 1: Direct JSON array
85    trace.strategies_tried.push("direct_json_array");
86    if let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed) {
87        trace.extracted_span = Some((0, trimmed.len()));
88        return Ok(arr);
89    }
90
91    // Preprocess: strip think tags, trim
92    let cleaned = preprocess_opts(trimmed, opts.strip_think_tags);
93
94    if cleaned.is_empty() {
95        return Err(ParseError::EmptyResponse);
96    }
97
98    // Strategy 1b: JSON array after preprocessing
99    trace.strategies_tried.push("json_array_preprocessed");
100    if let Ok(arr) = serde_json::from_str::<Vec<String>>(&cleaned) {
101        trace.extracted_span = Some((0, cleaned.len()));
102        return Ok(arr);
103    }
104
105    // Strategy 2: JSON object with common list keys
106    trace.strategies_tried.push("json_object_list_keys");
107    if let Some(tags) = try_extract_list_from_object(&cleaned) {
108        trace
109            .warnings
110            .push("list extracted from object wrapper".to_string());
111        return Ok(tags);
112    }
113
114    // Strategy 3: Markdown code block extraction
115    if opts.allow_code_fences {
116        trace.strategies_tried.push("code_block_list");
117        if let Some(tags) = extract_list_from_code_block(&cleaned) {
118            return Ok(tags);
119        }
120    }
121
122    // Strategy 4: Bracket-matched JSON array (with depth limit)
123    trace.strategies_tried.push("bracket_match_array");
124    if let Some(bracket_str) = find_bracketed_limited(&cleaned, '[', ']', opts.max_nesting_depth)? {
125        record_extracted_span(trace, &cleaned, bracket_str);
126        if let Ok(arr) = serde_json::from_str::<Vec<String>>(bracket_str) {
127            return Ok(arr);
128        }
129        // Try repair on the bracketed substring (bounded)
130        if opts.max_repair_attempts > 0 {
131            trace.strategies_tried.push("repair_bracket");
132            if let Some(repaired) = try_repair_json(bracket_str) {
133                trace.repaired = true;
134                trace
135                    .repair_actions
136                    .push("repaired_bracket_array".to_string());
137                if let Ok(arr) = serde_json::from_str::<Vec<String>>(&repaired) {
138                    return Ok(arr);
139                }
140            }
141        }
142    }
143
144    // Strategy 5: Repair on the full cleaned text (bounded)
145    if opts.max_repair_attempts > 0 {
146        trace.strategies_tried.push("repair_cleaned");
147        if let Some(repaired) = try_repair_json(&cleaned) {
148            trace.repaired = true;
149            trace
150                .repair_actions
151                .push("repaired_cleaned_text".to_string());
152            if let Ok(arr) = serde_json::from_str::<Vec<String>>(&repaired) {
153                return Ok(arr);
154            }
155            if let Some(tags) = try_extract_list_from_object(&repaired) {
156                return Ok(tags);
157            }
158        }
159    }
160
161    // Strategy 6: Numbered/bulleted list extraction
162    trace.strategies_tried.push("numbered_bulleted_list");
163    if let Some(tags) = extract_from_list(&cleaned) {
164        return Ok(tags);
165    }
166
167    // Strategy 7: Comma-separated fallback
168    trace.strategies_tried.push("comma_separated");
169    let tags: Vec<String> = cleaned
170        .split(',')
171        .map(|s| s.trim().trim_matches('"').trim().to_string())
172        .filter(|s| !s.is_empty())
173        .collect();
174
175    if tags.is_empty() {
176        return Err(ParseError::Unparseable {
177            expected_format: "string list",
178            text: truncate(&cleaned, 200),
179        });
180    }
181
182    trace
183        .warnings
184        .push("fell back to comma-separated parsing".to_string());
185    Ok(tags)
186}
187
188/// Try parsing as a JSON object and extracting an array from common keys.
189fn try_extract_list_from_object(text: &str) -> Option<Vec<String>> {
190    let val: serde_json::Value = serde_json::from_str(text).ok()?;
191    for key in ["tags", "items", "results", "list"] {
192        if let Some(arr) = val.get(key).and_then(|v| v.as_array()) {
193            let tags: Vec<String> = arr
194                .iter()
195                .filter_map(|v| v.as_str().map(|s| s.to_string()))
196                .collect();
197            if !tags.is_empty() {
198                return Some(tags);
199            }
200        }
201    }
202    None
203}
204
205/// Extract a list from a markdown code block.
206fn extract_list_from_code_block(text: &str) -> Option<Vec<String>> {
207    // Try ```json blocks first, then any block
208    if let Some((lang, content)) = extract_code_block(text) {
209        // Try direct array parse
210        if let Ok(arr) = serde_json::from_str::<Vec<String>>(content) {
211            return Some(arr);
212        }
213        // Try object with list keys
214        if let Some(tags) = try_extract_list_from_object(content) {
215            return Some(tags);
216        }
217        // If it was a json block and still failed, try repair
218        if lang == Some("json") {
219            if let Some(repaired) = try_repair_json(content) {
220                if let Ok(arr) = serde_json::from_str::<Vec<String>>(&repaired) {
221                    return Some(arr);
222                }
223            }
224        }
225    }
226    None
227}
228
229/// Extract tags from numbered or bulleted lists.
230fn extract_from_list(text: &str) -> Option<Vec<String>> {
231    let lines: Vec<&str> = text.lines().collect();
232    let list_items: Vec<String> = lines
233        .iter()
234        .filter_map(|line| {
235            let trimmed = line.trim();
236            // Numbered: "1. tag", "2) tag"
237            if let Some(rest) = trimmed
238                .strip_prefix(|c: char| c.is_ascii_digit())
239                .and_then(|s| {
240                    // Handle multi-digit numbers
241                    let s = s.trim_start_matches(|c: char| c.is_ascii_digit());
242                    s.strip_prefix('.').or_else(|| s.strip_prefix(')'))
243                })
244            {
245                let tag = rest.trim().trim_matches('"').trim();
246                if !tag.is_empty() {
247                    return Some(tag.to_string());
248                }
249            }
250            // Bulleted: "- tag", "* tag", "\u{2022} tag"
251            for prefix in ["-", "*", "\u{2022}"] {
252                if let Some(rest) = trimmed.strip_prefix(prefix) {
253                    let tag = rest.trim().trim_matches('"').trim();
254                    if !tag.is_empty() {
255                        return Some(tag.to_string());
256                    }
257                }
258            }
259            None
260        })
261        .collect();
262
263    if list_items.len() >= 2 {
264        Some(list_items)
265    } else {
266        None
267    }
268}
269
270/// Clean a list of tags: lowercase, trim, deduplicate, filter empties and long items.
271fn clean_tags(tags: Vec<String>) -> Vec<String> {
272    let mut seen = HashSet::new();
273    tags.into_iter()
274        .map(|t| t.trim().to_lowercase())
275        .filter(|t| !t.is_empty() && t.len() < 50 && seen.insert(t.clone()))
276        .collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    // ====================================================
284    // Ported from ollama-vision/src/parser.rs (24 tests)
285    // ====================================================
286
287    // -- Strategy 1: Direct JSON array --
288
289    #[test]
290    fn parse_json_array() {
291        let input = r#"["portrait", "fantasy", "dark lighting"]"#;
292        let tags = parse_string_list(input).unwrap();
293        assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
294    }
295
296    // -- Strategy 2: Think tags + JSON --
297
298    #[test]
299    fn parse_with_think_blocks() {
300        let input = r#"<think>
301Let me analyze this image. I see a portrait with dark lighting...
302</think>
303
304["portrait", "dark lighting", "woman"]"#;
305        let tags = parse_string_list(input).unwrap();
306        assert_eq!(tags, vec!["portrait", "dark lighting", "woman"]);
307    }
308
309    #[test]
310    fn parse_with_incomplete_think_block() {
311        let input = "<think>\nStill thinking...\n[\"portrait\", \"fantasy\"]";
312        let result = parse_string_list(input);
313        assert!(result.is_err());
314    }
315
316    #[test]
317    fn strip_think_tags_complete() {
318        let input = "<think>reasoning</think>result";
319        assert_eq!(crate::strip_think_tags(input), "result");
320    }
321
322    #[test]
323    fn strip_think_tags_incomplete() {
324        let input = "<think>reasoning without close";
325        assert_eq!(crate::strip_think_tags(input), "");
326    }
327
328    #[test]
329    fn strip_think_tags_multiple() {
330        let input = "<think>first</think>middle<think>second</think>end";
331        assert_eq!(crate::strip_think_tags(input), "middleend");
332    }
333
334    // -- Strategy 3: JSON object with "tags" key --
335
336    #[test]
337    fn parse_object_with_tags_key() {
338        let input = r#"{"tags": ["portrait", "dark", "moody"]}"#;
339        let tags = parse_string_list(input).unwrap();
340        assert_eq!(tags, vec!["portrait", "dark", "moody"]);
341    }
342
343    #[test]
344    fn parse_think_then_object() {
345        let input = r#"<think>Looking at this...</think>{"tags": ["cat", "cute", "indoor"]}"#;
346        let tags = parse_string_list(input).unwrap();
347        assert_eq!(tags, vec!["cat", "cute", "indoor"]);
348    }
349
350    // -- Strategy 4: Markdown code blocks --
351
352    #[test]
353    fn parse_markdown_code_block() {
354        let input =
355            "Here are the tags:\n\n```json\n[\"portrait\", \"fantasy\", \"oil painting\"]\n```";
356        let tags = parse_string_list(input).unwrap();
357        assert_eq!(tags, vec!["portrait", "fantasy", "oil painting"]);
358    }
359
360    #[test]
361    fn parse_think_then_code_block() {
362        let input = "<think>\nAnalyzing...\n</think>\n\n```json\n[\"landscape\", \"sunset\"]\n```";
363        let tags = parse_string_list(input).unwrap();
364        assert_eq!(tags, vec!["landscape", "sunset"]);
365    }
366
367    #[test]
368    fn parse_code_block_with_object() {
369        let input = "```json\n{\"tags\": [\"a\", \"b\"]}\n```";
370        let tags = parse_string_list(input).unwrap();
371        assert_eq!(tags, vec!["a", "b"]);
372    }
373
374    // -- Strategy 5: Bracket matching --
375
376    #[test]
377    fn parse_with_surrounding_text() {
378        let input = r#"Here are the tags: ["cat", "cute", "indoor"]"#;
379        let tags = parse_string_list(input).unwrap();
380        assert_eq!(tags, vec!["cat", "cute", "indoor"]);
381    }
382
383    #[test]
384    fn parse_mixed_text_and_json() {
385        let input = "I found these:\n[\"a\", \"b\"]\nHope that helps!";
386        let tags = parse_string_list(input).unwrap();
387        assert_eq!(tags, vec!["a", "b"]);
388    }
389
390    // -- Strategy 6: List extraction --
391
392    #[test]
393    fn parse_numbered_list() {
394        let input = "1. portrait\n2. fantasy\n3. dark lighting";
395        let tags = parse_string_list(input).unwrap();
396        assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
397    }
398
399    #[test]
400    fn parse_bulleted_list() {
401        let input = "- portrait\n- fantasy\n- dark lighting";
402        let tags = parse_string_list(input).unwrap();
403        assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
404    }
405
406    #[test]
407    fn parse_star_bulleted_list() {
408        let input = "* cat\n* cute\n* fluffy";
409        let tags = parse_string_list(input).unwrap();
410        assert_eq!(tags, vec!["cat", "cute", "fluffy"]);
411    }
412
413    // -- Strategy 7: Comma-separated fallback --
414
415    #[test]
416    fn parse_comma_separated() {
417        let input = "portrait, fantasy, dark lighting";
418        let tags = parse_string_list(input).unwrap();
419        assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
420    }
421
422    // -- Edge cases --
423
424    #[test]
425    fn parse_empty_fails() {
426        assert!(parse_string_list("").is_err());
427        assert!(parse_string_list("   ").is_err());
428    }
429
430    #[test]
431    fn parse_cleans_whitespace_and_case() {
432        let input = r#"["  Portrait  ", " FANTASY ", "Dark Lighting"]"#;
433        let tags = parse_string_list(input).unwrap();
434        assert_eq!(tags, vec!["portrait", "fantasy", "dark lighting"]);
435    }
436
437    #[test]
438    fn parse_deduplicates() {
439        let input = r#"["cat", "Cat", "CAT", "dog"]"#;
440        let tags = parse_string_list(input).unwrap();
441        assert_eq!(tags, vec!["cat", "dog"]);
442    }
443
444    #[test]
445    fn parse_filters_long_tags() {
446        let input = format!(r#"["good", "{}"]"#, "x".repeat(60));
447        let tags = parse_string_list(&input).unwrap();
448        assert_eq!(tags, vec!["good"]);
449    }
450
451    #[test]
452    fn clean_tags_filters_empty() {
453        let tags = vec!["good".to_string(), "".to_string(), "  ".to_string()];
454        let cleaned = clean_tags(tags);
455        assert_eq!(cleaned, vec!["good"]);
456    }
457
458    // ====================================================
459    // New tests for generalized functionality
460    // ====================================================
461
462    #[test]
463    fn json_object_with_items_key() {
464        let input = r#"{"items": ["a", "b"]}"#;
465        let tags = parse_string_list(input).unwrap();
466        assert_eq!(tags, vec!["a", "b"]);
467    }
468
469    #[test]
470    fn json_object_with_results_key() {
471        let input = r#"{"results": ["a", "b"]}"#;
472        let tags = parse_string_list(input).unwrap();
473        assert_eq!(tags, vec!["a", "b"]);
474    }
475
476    #[test]
477    fn json_object_with_list_key() {
478        let input = r#"{"list": ["a", "b"]}"#;
479        let tags = parse_string_list(input).unwrap();
480        assert_eq!(tags, vec!["a", "b"]);
481    }
482
483    #[test]
484    fn repaired_json_list() {
485        let input = "['tag1', 'tag2']";
486        let tags = parse_string_list(input).unwrap();
487        assert_eq!(tags, vec!["tag1", "tag2"]);
488    }
489
490    #[test]
491    fn raw_preserves_case() {
492        let input = r#"["Alpha", "Beta"]"#;
493        let tags = parse_string_list_raw(input).unwrap();
494        assert_eq!(tags, vec!["Alpha", "Beta"]);
495    }
496
497    #[test]
498    fn raw_preserves_length() {
499        let long_item = "x".repeat(60);
500        let input = format!(r#"["short", "{}"]"#, long_item);
501        let tags = parse_string_list_raw(&input).unwrap();
502        assert_eq!(tags.len(), 2);
503        assert_eq!(tags[1], long_item);
504    }
505
506    #[test]
507    fn raw_comma_separated() {
508        let input = "Alpha, Beta, Gamma";
509        let tags = parse_string_list_raw(input).unwrap();
510        assert_eq!(tags, vec!["Alpha", "Beta", "Gamma"]);
511    }
512
513    #[test]
514    fn thinking_tag_variant() {
515        let input = r#"<thinking>analyzing...</thinking>["a", "b"]"#;
516        let tags = parse_string_list(input).unwrap();
517        assert_eq!(tags, vec!["a", "b"]);
518    }
519
520    // ====================================================
521    // Traced variant tests
522    // ====================================================
523
524    #[test]
525    fn parse_string_list_with_trace_direct() {
526        let input = r#"["alpha", "beta"]"#;
527        let opts = ParseOptions::default();
528        let (tags, trace) = parse_string_list_with_trace(input, &opts).unwrap();
529        assert_eq!(tags, vec!["alpha", "beta"]);
530        assert!(trace.strategies_tried.contains(&"direct_json_array"));
531        assert!(!trace.repaired);
532    }
533
534    #[test]
535    fn parse_string_list_with_trace_repair() {
536        let input = "['tag1', 'tag2']";
537        let opts = ParseOptions::default();
538        let (tags, trace) = parse_string_list_with_trace(input, &opts).unwrap();
539        assert_eq!(tags, vec!["tag1", "tag2"]);
540        assert!(trace.repaired);
541    }
542
543    #[test]
544    fn parse_string_list_with_trace_too_large() {
545        let input = "x".repeat(100);
546        let opts = ParseOptions {
547            max_input_bytes: 50,
548            ..Default::default()
549        };
550        let result = parse_string_list_with_trace(&input, &opts);
551        match result {
552            Err(ParseError::TooLarge {
553                size: 100,
554                limit: 50,
555            }) => {}
556            other => panic!("expected TooLarge, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn parse_string_list_with_trace_strategies_recorded() {
562        let input = "- alpha\n- beta\n- gamma";
563        let opts = ParseOptions::default();
564        let (tags, trace) = parse_string_list_with_trace(input, &opts).unwrap();
565        assert_eq!(tags, vec!["alpha", "beta", "gamma"]);
566        // Should have tried several strategies before reaching bulleted list
567        assert!(trace.strategies_tried.len() >= 3);
568        assert!(trace.strategies_tried.contains(&"numbered_bulleted_list"));
569    }
570}