Skip to main content

oxicode_ai/router/
classifier.rs

1//! Classifiers for routing decisions.
2//!
3//! Two-stage pipeline:
4//! 1. **Heuristic** — fast, no LLM call, based on **language-agnostic structural signals**.
5//! 2. **LLM** (optional) — refines ambiguous cases by asking a small model.
6//!
7//! The heuristic stage uses only quantitative/structural signals:
8//! message length, line count, code blocks, file paths, symbol density,
9//! question form, context tokens, turn count. No keyword matching.
10//!
11//! The LLM stage is only activated when `classifier_model` is configured
12//! and the heuristic score falls in the ambiguous zone (0.25–0.75).
13
14use anyhow::Result;
15
16// ── Classifier Input ─────────────────────────────────────────────────────────
17
18/// Input for the classifier — metadata about the current request.
19#[derive(Debug, Clone)]
20pub struct ClassifierInput {
21    /// User message text.
22    pub message: String,
23    /// Estimated context token count.
24    pub context_tokens: usize,
25    /// Number of conversation turns so far.
26    pub turn_count: usize,
27    /// Tool names available in this session.
28    pub available_tools: Vec<String>,
29}
30
31impl ClassifierInput {
32    /// Check if the message contains code blocks (``` markers).
33    pub fn contains_code_blocks(&self) -> bool {
34        self.message.contains("```")
35    }
36
37    /// Check if the message references file paths.
38    ///
39    /// Detects patterns like `src/main.rs`, `lib/config.ts`, `/etc/hosts`.
40    /// Language-agnostic — relies on path structure, not words.
41    pub fn contains_file_paths(&self) -> bool {
42        let msg = self.message.as_bytes();
43        let mut i = 0;
44        while i < msg.len() {
45            if msg[i] == b'/' || msg[i] == b'\\' {
46                for j in (i + 1)..std::cmp::min(i + 20, msg.len()) {
47                    if msg[j] == b'.' && j + 1 < msg.len() && msg[j + 1].is_ascii_alphabetic() {
48                        return true;
49                    }
50                }
51            }
52            i += 1;
53        }
54        false
55    }
56
57    /// Count the number of lines in the message.
58    pub fn line_count(&self) -> usize {
59        self.message.lines().count().max(1)
60    }
61
62    /// Compute symbol density — ratio of code-like characters.
63    ///
64    /// Counts `{`, `}`, `(`, `)`, `[`, `]`, `<`, `>`, `=`, `;`, `:`,
65    /// `|`, `&`, `!`, `@`, `#`, `$`, `%`, `^`, `*`, `+`, `-`, `/`.
66    /// Higher density suggests code, configuration, or technical content.
67    pub fn symbol_density(&self) -> f64 {
68        if self.message.is_empty() {
69            return 0.0;
70        }
71        let code_symbols: &[u8] = b"{}()[]<>=;|&!@#$%^*+-/:\\";
72        let count = self
73            .message
74            .bytes()
75            .filter(|b| code_symbols.contains(b))
76            .count();
77        count as f64 / self.message.len() as f64
78    }
79
80    /// Check if the message ends with a question mark.
81    pub fn is_question(&self) -> bool {
82        self.message.trim().ends_with('?')
83    }
84
85    /// Check if the message appears to be a short, single-sentence statement.
86    ///
87    /// Heuristic: ≤ 3 words and no newlines.
88    pub fn is_single_sentence(&self) -> bool {
89        let trimmed = self.message.trim();
90        !trimmed.contains('\n') && trimmed.split_whitespace().count() <= 3
91    }
92
93    /// Count distinct file path references.
94    pub fn file_path_count(&self) -> usize {
95        let msg = self.message.as_bytes();
96        let mut count = 0;
97        let mut i = 0;
98        while i < msg.len() {
99            if msg[i] == b'/' || msg[i] == b'\\' {
100                for j in (i + 1)..std::cmp::min(i + 20, msg.len()) {
101                    if msg[j] == b'.' && j + 1 < msg.len() && msg[j + 1].is_ascii_alphabetic() {
102                        count += 1;
103                        // Skip past this path
104                        i = j + 1;
105                        break;
106                    }
107                }
108            }
109            i += 1;
110        }
111        count
112    }
113}
114
115// ── Heuristic Classifier ─────────────────────────────────────────────────────
116
117/// Fast classifier based entirely on **language-agnostic structural signals**.
118///
119/// No keyword matching. Works identically for English, Korean, Japanese,
120/// or any other language. Signals used:
121///
122/// | Signal | What it measures |
123/// |--------|-----------------|
124/// | Message length | Longer = more complex request |
125/// | Line count | Multi-line = structured request |
126/// | Code blocks (```) | Code-related task |
127/// | File paths | File operation task |
128/// | Symbol density | Technical/code content |
129/// | Question form (?) | Likely Q&A → lower tier |
130/// | Single sentence | Simple → lower tier |
131/// | Context tokens | Rich conversation → higher |
132/// | Turn count | More turns → richer context |
133///
134/// Produces a score in `[0.0, 1.0]`:
135/// - `≤ 0.3` → simple Q&A, greeting, single-line → `Low` tier
136/// - `0.3–0.7` → moderate → `Medium` tier
137/// - `≥ 0.7` → complex, multi-file, code-heavy → `High` tier
138#[derive(Debug, Clone)]
139pub struct HeuristicClassifier {
140    /// Context length threshold for "high" (tokens).
141    context_threshold_high: usize,
142    /// Context length threshold for "low" (tokens).
143    context_threshold_low: usize,
144}
145
146impl Default for HeuristicClassifier {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl HeuristicClassifier {
153    /// Create with default thresholds.
154    pub fn new() -> Self {
155        Self {
156            context_threshold_high: 20_000,
157            context_threshold_low: 2_000,
158        }
159    }
160
161    /// Classify the input, returning a complexity score in `[0.0, 1.0]`.
162    pub fn classify(&self, input: &ClassifierInput) -> f64 {
163        let mut score = 0.0;
164
165        // 1. Message length weight (longer → more complex)
166        score += self.length_weight(input.message.len());
167
168        // 2. Line count weight (multi-line → structured request)
169        score += self.line_weight(input.line_count());
170
171        // 3. Code block presence
172        if input.contains_code_blocks() {
173            score += 0.12;
174        }
175
176        // 4. File path references
177        let path_count = input.file_path_count();
178        if path_count > 0 {
179            // More files → higher complexity (1 file: 0.08, 2: 0.14, 3+: 0.18)
180            score += (0.08 + 0.06 * (path_count - 1).min(2) as f64).min(0.20);
181        }
182
183        // 5. Symbol density (high density → technical content)
184        score += self.symbol_density_weight(input.symbol_density());
185
186        // 6. Context token weight
187        score += self.context_weight(input.context_tokens);
188
189        // 7. Turn count (more turns → richer conversation → slightly higher)
190        score += self.turn_weight(input.turn_count);
191
192        // 8. Down-weight simple patterns
193        if input.is_single_sentence() {
194            score -= 0.08;
195        }
196        if input.is_question() && input.message.len() < 80 {
197            // Short question → likely Q&A, not implementation
198            score -= 0.06;
199        }
200
201        score.clamp(0.0, 1.0)
202    }
203
204    // ── Weight helpers ───────────────────────────────────────────────
205
206    /// Weight from message length (characters).
207    fn length_weight(&self, len: usize) -> f64 {
208        if len < 20 {
209            0.0
210        } else if len < 60 {
211            0.05
212        } else if len < 200 {
213            0.12
214        } else if len < 600 {
215            0.22
216        } else if len < 2000 {
217            0.32
218        } else {
219            0.38
220        }
221    }
222
223    /// Weight from line count.
224    fn line_weight(&self, lines: usize) -> f64 {
225        if lines <= 1 {
226            0.0
227        } else if lines <= 3 {
228            0.03
229        } else if lines <= 10 {
230            0.08
231        } else {
232            0.12
233        }
234    }
235
236    /// Weight from symbol density.
237    ///
238    /// Normal prose: ~0.02–0.05, Code: ~0.15–0.30, Config/JSON: ~0.20–0.35
239    fn symbol_density_weight(&self, density: f64) -> f64 {
240        if density < 0.03 {
241            0.0
242        } else if density < 0.08 {
243            0.02
244        } else if density < 0.15 {
245            0.06
246        } else {
247            // High symbol density — code/config heavy
248            0.10
249        }
250    }
251
252    /// Weight from context token count.
253    fn context_weight(&self, tokens: usize) -> f64 {
254        if tokens < self.context_threshold_low {
255            0.0
256        } else if tokens < self.context_threshold_high {
257            let ratio = (tokens - self.context_threshold_low) as f64
258                / (self.context_threshold_high - self.context_threshold_low) as f64;
259            0.12 * ratio
260        } else {
261            0.12
262        }
263    }
264
265    /// Weight from turn count.
266    fn turn_weight(&self, turns: usize) -> f64 {
267        if turns < 2 {
268            0.0
269        } else if turns < 5 {
270            0.02
271        } else if turns < 10 {
272            0.04
273        } else {
274            0.06
275        }
276    }
277}
278
279// ── LLM Classifier ───────────────────────────────────────────────────────────
280
281/// LLM-based classifier for refining ambiguous heuristic scores.
282///
283/// When `classifier_model` is configured (e.g. `"anthropic/claude-haiku-4"`),
284/// this classifier uses a fast/cheap model to classify ambiguous requests.
285/// It only activates when the heuristic score falls in the ambiguous zone (0.25–0.75).
286///
287/// The LLM understands any language natively — no keyword lists needed.
288#[derive(Debug, Clone, Default)]
289pub struct LlmClassifier {
290    /// The model to use for classification, in `"provider/model-id"` format.
291    pub model: Option<String>,
292}
293
294impl LlmClassifier {
295    /// Create a new LLM classifier.
296    pub fn new(model: Option<String>) -> Self {
297        Self { model }
298    }
299
300    /// Classify a user message to determine routing tier.
301    ///
302    /// Sends a minimal prompt to the configured classifier model and parses
303    /// the response as "high", "medium", or "low".
304    ///
305    /// Returns a score:
306    /// - `0.1` for `low`
307    /// - `0.5` for `medium`
308    /// - `0.9` for `high`
309    ///
310    /// Falls back to the provided `heuristic_score` on any error.
311    pub async fn classify(&self, input: &ClassifierInput, heuristic_score: f64) -> Result<f64> {
312        let model_str = self
313            .model
314            .as_deref()
315            .ok_or_else(|| anyhow::anyhow!("no classifier model configured"))?;
316
317        let (provider_name, model_id) = model_str
318            .split_once('/')
319            .ok_or_else(|| anyhow::anyhow!("invalid classifier model format: {model_str}"))?;
320
321        // Resolve provider
322        let provider = crate::providers::get_provider_arc(provider_name)
323            .ok_or_else(|| anyhow::anyhow!("unknown provider: {provider_name}"))?;
324
325        // Build a minimal model
326        let model = crate::types::Model::new(
327            model_id,
328            model_id,
329            crate::Api::AnthropicMessages,
330            provider_name,
331            "",
332        );
333
334        // Build the classifier prompt
335        let prompt = build_classifier_prompt(input, heuristic_score);
336
337        // Build a minimal context
338        let context = crate::context::Context {
339            system_prompt: Some(
340                "You are a model router classifier. Reply with exactly one word: high, medium, or low."
341                    .to_string(),
342            ),
343            messages: vec![crate::messages::Message::User(
344                crate::messages::UserMessage {
345                    role: crate::messages::UserRole::User,
346                    content: crate::messages::MessageContent::Text(prompt),
347                    timestamp: 0,
348                    visible: true,
349                },
350            )],
351            tools: vec![],
352        };
353
354        // Stream and collect response
355        let stream = provider
356            .stream(&model, &context, None)
357            .await
358            .map_err(|e| anyhow::anyhow!("classifier stream error: {e}"))?;
359
360        let text = collect_stream_text(stream).await?;
361        parse_tier_from_response(&text, heuristic_score)
362    }
363}
364
365/// Build the classifier prompt from input and heuristic score.
366fn build_classifier_prompt(input: &ClassifierInput, heuristic_score: f64) -> String {
367    let msg_preview = if input.message.len() > 500 {
368        format!("{}...", &input.message[..500])
369    } else {
370        input.message.clone()
371    };
372
373    format!(
374        "Categorize this request into one tier:\n\
375             - high: architecture, design, planning, complex debugging, large refactors\n\
376             - medium: implementation, normal coding, multi-file edits\n\
377             - low: summaries, formatting, quick questions, simple lookups\n\
378             \n\
379             Context tokens: {}\n\
380             Turn count: {}\n\
381             Heuristic score: {heuristic_score:.2}\n\
382             \n\
383             User request:\n\
384             {msg_preview}\n\
385             \n\
386             Reply with exactly one word: high, medium, or low",
387        input.context_tokens, input.turn_count
388    )
389}
390
391/// Collect all text from a provider event stream.
392async fn collect_stream_text(
393    stream: std::pin::Pin<Box<dyn futures::Stream<Item = crate::ProviderEvent> + Send>>,
394) -> Result<String> {
395    use futures::StreamExt;
396
397    let mut text = String::new();
398    let mut stream = stream;
399
400    while let Some(event) = stream.next().await {
401        match event {
402            crate::ProviderEvent::TextDelta { delta, .. } => {
403                text.push_str(&delta);
404            }
405            crate::ProviderEvent::Done { .. } => break,
406            crate::ProviderEvent::Error { reason, .. } => {
407                anyhow::bail!("classifier stream error: {reason:?}");
408            }
409            _ => {}
410        }
411    }
412
413    Ok(text)
414}
415
416/// Parse the LLM response text to extract a tier score.
417///
418/// Looks for "high", "medium", or "low" in the response (case-insensitive).
419/// Falls back to the heuristic score if parsing fails.
420fn parse_tier_from_response(text: &str, fallback: f64) -> Result<f64> {
421    let lower = text.to_lowercase();
422
423    // Check for exact matches first
424    if lower.contains("high") {
425        return Ok(0.9);
426    }
427    if lower.contains("medium") {
428        return Ok(0.5);
429    }
430    if lower.contains("low") {
431        return Ok(0.1);
432    }
433
434    // Fallback to heuristic
435    tracing::warn!(
436        "LLM classifier returned unparseable response: '{text}', falling back to heuristic score {fallback:.2}"
437    );
438    Ok(fallback)
439}
440
441// ── Tests ─────────────────────────────────────────────────────────────────────
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn make_input(message: &str) -> ClassifierInput {
448        ClassifierInput {
449            message: message.to_string(),
450            context_tokens: 0,
451            turn_count: 0,
452            available_tools: vec![],
453        }
454    }
455
456    fn make_input_with_context(message: &str, tokens: usize, turns: usize) -> ClassifierInput {
457        ClassifierInput {
458            message: message.to_string(),
459            context_tokens: tokens,
460            turn_count: turns,
461            available_tools: vec![],
462        }
463    }
464
465    // ── Low-tier: short, simple messages ─────────────────────────────
466
467    #[test]
468    fn simple_greeting() {
469        let classifier = HeuristicClassifier::new();
470        let score = classifier.classify(&make_input("hello"));
471        assert!(score < 0.1, "greeting should score very low, got {score}");
472    }
473
474    #[test]
475    fn simple_thanks() {
476        let classifier = HeuristicClassifier::new();
477        let score = classifier.classify(&make_input("thank you!"));
478        assert!(score < 0.1, "thanks should score very low, got {score}");
479    }
480
481    #[test]
482    fn korean_greeting() {
483        let classifier = HeuristicClassifier::new();
484        let score = classifier.classify(&make_input("안녕하세요"));
485        assert!(
486            score < 0.1,
487            "korean greeting should score very low, got {score}"
488        );
489    }
490
491    #[test]
492    fn short_question() {
493        let classifier = HeuristicClassifier::new();
494        let score = classifier.classify(&make_input("what is rust?"));
495        assert!(score < 0.1, "short question should score low, got {score}");
496    }
497
498    #[test]
499    fn japanese_short() {
500        let classifier = HeuristicClassifier::new();
501        let score = classifier.classify(&make_input("これ何?"));
502        assert!(score < 0.1, "japanese short should score low, got {score}");
503    }
504
505    // ── Medium-tier: moderate length, some structure ─────────────────
506
507    #[test]
508    fn medium_request() {
509        let classifier = HeuristicClassifier::new();
510        let score = classifier.classify(&make_input(
511            "Modify the config file to add the new endpoint for the auth service",
512        ));
513        assert!(
514            (0.05..0.35).contains(&score),
515            "medium request should score modest, got {score}"
516        );
517    }
518
519    #[test]
520    fn multi_line_request() {
521        let classifier = HeuristicClassifier::new();
522        let score = classifier.classify(&make_input(
523            "I need to update the following:\n- config file\n- router\n- middleware",
524        ));
525        assert!(
526            score > 0.1,
527            "multi-line should score higher than single line, got {score}"
528        );
529    }
530
531    // ── High-tier: long, code, files, technical ──────────────────────
532
533    #[test]
534    fn long_request_with_code_blocks() {
535        let classifier = HeuristicClassifier::new();
536        let score = classifier.classify(&make_input(
537            "Debug this error:\n```rust\nfn main() { panic!() }\n```\nThe stack trace shows a null pointer.",
538        ));
539        assert!(
540            score >= 0.25,
541            "code block request should score medium+, got {score}"
542        );
543    }
544
545    #[test]
546    fn multi_file_request() {
547        let classifier = HeuristicClassifier::new();
548        let score = classifier.classify(&make_input(
549            "Update src/main.rs and lib/config.rs to implement the new API",
550        ));
551        assert!(
552            score >= 0.15,
553            "multi-file should score medium+, got {score}"
554        );
555    }
556
557    #[test]
558    fn long_technical_request() {
559        let classifier = HeuristicClassifier::new();
560        let score = classifier.classify(&make_input(&format!(
561            "I need to implement a distributed event sourcing system with CQRS. \
562             The system should support: (1) event store with append-only log, \
563             (2) command bus with validation, (3) query side with materialized views, \
564             (4) saga orchestration for distributed transactions. \
565             Here's my current architecture:\n{}\nPlease review and suggest improvements.",
566            "x".repeat(200)
567        )));
568        assert!(
569            score >= 0.2,
570            "long technical request should score medium+, got {score}"
571        );
572    }
573
574    #[test]
575    fn high_symbol_density() {
576        let classifier = HeuristicClassifier::new();
577        let score = classifier.classify(&make_input(
578            r#"{"type": "router", "config": {"high": {"model": "opus"}, "low": {"model": "haiku"}}}"#,
579        ));
580        assert!(
581            score >= 0.15,
582            "json/config should score higher due to symbol density, got {score}"
583        );
584    }
585
586    // ── Context and turn influence ───────────────────────────────────
587
588    #[test]
589    fn large_context_boosts_score() {
590        let classifier = HeuristicClassifier::new();
591        let low = classifier.classify(&make_input_with_context("hello", 0, 0));
592        let high = classifier.classify(&make_input_with_context("hello", 30_000, 15));
593        assert!(
594            high > low,
595            "large context should boost score: {low} vs {high}"
596        );
597    }
598
599    #[test]
600    fn turn_count_increases_score() {
601        let classifier = HeuristicClassifier::new();
602        let low = classifier.classify(&make_input_with_context("update this", 0, 0));
603        let high = classifier.classify(&make_input_with_context("update this", 10_000, 12));
604        assert!(
605            high >= low,
606            "more turns+context should boost score: {low} vs {high}"
607        );
608    }
609
610    // ── Structural detection ─────────────────────────────────────────
611
612    #[test]
613    fn detect_file_paths() {
614        let input = make_input("Look at src/main.rs for the bug");
615        assert!(input.contains_file_paths());
616        assert_eq!(input.file_path_count(), 1);
617    }
618
619    #[test]
620    fn detect_multiple_file_paths() {
621        let input = make_input("Update src/main.rs and lib/config.rs");
622        assert_eq!(input.file_path_count(), 2);
623    }
624
625    #[test]
626    fn no_file_paths() {
627        let input = make_input("What is a closure?");
628        assert!(!input.contains_file_paths());
629    }
630
631    #[test]
632    fn detect_code_blocks() {
633        let input = make_input("Here is the code:\n```rust\nfn main() {}\n```");
634        assert!(input.contains_code_blocks());
635    }
636
637    #[test]
638    fn no_code_blocks() {
639        let input = make_input("Just a plain message");
640        assert!(!input.contains_code_blocks());
641    }
642
643    #[test]
644    fn detect_question() {
645        let input = make_input("what is this?");
646        assert!(input.is_question());
647    }
648
649    #[test]
650    fn detect_single_sentence() {
651        let input = make_input("hello world");
652        assert!(input.is_single_sentence());
653    }
654
655    #[test]
656    fn not_single_sentence() {
657        let input = make_input("hello\nworld");
658        assert!(!input.is_single_sentence());
659    }
660
661    #[test]
662    fn symbol_density_plain_text() {
663        let input = make_input("hello world this is a test");
664        assert!(input.symbol_density() < 0.05);
665    }
666
667    #[test]
668    fn symbol_density_code() {
669        let input = make_input("fn main() -> Result<Vec<String>> { Ok(vec![]) }");
670        let density = input.symbol_density();
671        assert!(
672            density > 0.10,
673            "code should have high symbol density, got {density}"
674        );
675    }
676
677    #[test]
678    fn line_count_single() {
679        let input = make_input("hello");
680        assert_eq!(input.line_count(), 1);
681    }
682
683    #[test]
684    fn line_count_multi() {
685        let input = make_input("line1\nline2\nline3");
686        assert_eq!(input.line_count(), 3);
687    }
688
689    // ── Score bounds ─────────────────────────────────────────────────
690
691    #[test]
692    fn score_always_in_bounds() {
693        let classifier = HeuristicClassifier::new();
694        let inputs = vec![
695            make_input(""),
696            make_input(&"x".repeat(10000)),
697            make_input_with_context("", 100_000, 100),
698            make_input("hello thanks 안녕 こんにちは"),
699            make_input("```python\nprint('hello')\n```"),
700        ];
701        for input in &inputs {
702            let score = classifier.classify(input);
703            assert!((0.0..=1.0).contains(&score), "score out of bounds: {score}");
704        }
705    }
706
707    // ── Language independence ─────────────────────────────────────────
708
709    #[test]
710    fn language_independence_short() {
711        let classifier = HeuristicClassifier::new();
712        // All short messages should score low regardless of language
713        let short_messages = vec!["hello", "안녕", "こんにちは", "你好", "Привет", "مرحبا"];
714        for msg in &short_messages {
715            let score = classifier.classify(&make_input(msg));
716            assert!(
717                score < 0.1,
718                "short message '{msg}' should score very low, got {score}"
719            );
720        }
721    }
722
723    #[test]
724    fn language_independence_long_with_code() {
725        let classifier = HeuristicClassifier::new();
726        // Long messages with code should score high regardless of surrounding text language
727        let messages = vec![
728            format!(
729                "Refactor this:\n```\nfn main() {{}}\n```\n{}",
730                "x".repeat(100)
731            ),
732            format!(
733                "이 코드를 수정해:\n```\nfn main() {{}}\n```\n{}",
734                "x".repeat(100)
735            ),
736            format!(
737                "このコードを修正:\n```\nfn main() {{}}\n```\n{}",
738                "x".repeat(100)
739            ),
740        ];
741        for msg in &messages {
742            let score = classifier.classify(&make_input(msg));
743            assert!(
744                score > 0.2,
745                "long+code message should score medium+, got {score}"
746            );
747        }
748    }
749
750    // ── LLM classifier stub ──────────────────────────────────────────
751
752    #[tokio::test]
753    async fn llm_classifier_no_model_configured() {
754        let classifier = LlmClassifier::new(None);
755        let input = make_input("test");
756        let result = classifier.classify(&input, 0.5).await;
757        assert!(result.is_err());
758    }
759
760    #[test]
761    fn llm_classifier_default() {
762        let classifier = LlmClassifier::default();
763        assert!(classifier.model.is_none());
764    }
765
766    // ── parse_tier_from_response ─────────────────────────────────────
767
768    #[test]
769    fn parse_high_response() {
770        let score = super::parse_tier_from_response("high", 0.5).unwrap();
771        assert!((score - 0.9).abs() < 1e-6);
772    }
773
774    #[test]
775    fn parse_medium_response() {
776        let score = super::parse_tier_from_response("medium", 0.5).unwrap();
777        assert!((score - 0.5).abs() < 1e-6);
778    }
779
780    #[test]
781    fn parse_low_response() {
782        let score = super::parse_tier_from_response("low", 0.5).unwrap();
783        assert!((score - 0.1).abs() < 1e-6);
784    }
785
786    #[test]
787    fn parse_case_insensitive() {
788        let score = super::parse_tier_from_response("HIGH", 0.5).unwrap();
789        assert!((score - 0.9).abs() < 1e-6);
790    }
791
792    #[test]
793    fn parse_with_extra_text() {
794        let score = super::parse_tier_from_response("I think this is high tier", 0.5).unwrap();
795        assert!((score - 0.9).abs() < 1e-6);
796    }
797
798    #[test]
799    fn parse_unparseable_falls_back() {
800        let score = super::parse_tier_from_response("maybe", 0.42).unwrap();
801        assert!((score - 0.42).abs() < 1e-6);
802    }
803
804    // ── build_classifier_prompt ──────────────────────────────────────
805
806    #[test]
807    fn prompt_contains_user_message() {
808        let input = make_input("Debug the authentication module");
809        let prompt = super::build_classifier_prompt(&input, 0.5);
810        assert!(prompt.contains("Debug the authentication module"));
811        assert!(prompt.contains("high"));
812        assert!(prompt.contains("medium"));
813        assert!(prompt.contains("low"));
814    }
815
816    #[test]
817    fn prompt_truncates_long_message() {
818        let input = make_input(&"x".repeat(600));
819        let prompt = super::build_classifier_prompt(&input, 0.5);
820        assert!(prompt.contains("..."));
821        assert!(prompt.len() < 1000);
822    }
823}