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                },
349            )],
350            tools: vec![],
351        };
352
353        // Stream and collect response
354        let stream = provider
355            .stream(&model, &context, None)
356            .await
357            .map_err(|e| anyhow::anyhow!("classifier stream error: {e}"))?;
358
359        let text = collect_stream_text(stream).await?;
360        parse_tier_from_response(&text, heuristic_score)
361    }
362}
363
364/// Build the classifier prompt from input and heuristic score.
365fn build_classifier_prompt(input: &ClassifierInput, heuristic_score: f64) -> String {
366    let msg_preview = if input.message.len() > 500 {
367        format!("{}...", &input.message[..500])
368    } else {
369        input.message.clone()
370    };
371
372    format!(
373        "Categorize this request into one tier:\n\
374             - high: architecture, design, planning, complex debugging, large refactors\n\
375             - medium: implementation, normal coding, multi-file edits\n\
376             - low: summaries, formatting, quick questions, simple lookups\n\
377             \n\
378             Context tokens: {}\n\
379             Turn count: {}\n\
380             Heuristic score: {heuristic_score:.2}\n\
381             \n\
382             User request:\n\
383             {msg_preview}\n\
384             \n\
385             Reply with exactly one word: high, medium, or low",
386        input.context_tokens, input.turn_count
387    )
388}
389
390/// Collect all text from a provider event stream.
391async fn collect_stream_text(
392    stream: std::pin::Pin<Box<dyn futures::Stream<Item = crate::ProviderEvent> + Send>>,
393) -> Result<String> {
394    use futures::StreamExt;
395
396    let mut text = String::new();
397    let mut stream = stream;
398
399    while let Some(event) = stream.next().await {
400        match event {
401            crate::ProviderEvent::TextDelta { delta, .. } => {
402                text.push_str(&delta);
403            }
404            crate::ProviderEvent::Done { .. } => break,
405            crate::ProviderEvent::Error { reason, .. } => {
406                anyhow::bail!("classifier stream error: {reason:?}");
407            }
408            _ => {}
409        }
410    }
411
412    Ok(text)
413}
414
415/// Parse the LLM response text to extract a tier score.
416///
417/// Looks for "high", "medium", or "low" in the response (case-insensitive).
418/// Falls back to the heuristic score if parsing fails.
419fn parse_tier_from_response(text: &str, fallback: f64) -> Result<f64> {
420    let lower = text.to_lowercase();
421
422    // Check for exact matches first
423    if lower.contains("high") {
424        return Ok(0.9);
425    }
426    if lower.contains("medium") {
427        return Ok(0.5);
428    }
429    if lower.contains("low") {
430        return Ok(0.1);
431    }
432
433    // Fallback to heuristic
434    tracing::warn!(
435        "LLM classifier returned unparseable response: '{text}', falling back to heuristic score {fallback:.2}"
436    );
437    Ok(fallback)
438}
439
440// ── Tests ─────────────────────────────────────────────────────────────────────
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn make_input(message: &str) -> ClassifierInput {
447        ClassifierInput {
448            message: message.to_string(),
449            context_tokens: 0,
450            turn_count: 0,
451            available_tools: vec![],
452        }
453    }
454
455    fn make_input_with_context(message: &str, tokens: usize, turns: usize) -> ClassifierInput {
456        ClassifierInput {
457            message: message.to_string(),
458            context_tokens: tokens,
459            turn_count: turns,
460            available_tools: vec![],
461        }
462    }
463
464    // ── Low-tier: short, simple messages ─────────────────────────────
465
466    #[test]
467    fn simple_greeting() {
468        let classifier = HeuristicClassifier::new();
469        let score = classifier.classify(&make_input("hello"));
470        assert!(score < 0.1, "greeting should score very low, got {score}");
471    }
472
473    #[test]
474    fn simple_thanks() {
475        let classifier = HeuristicClassifier::new();
476        let score = classifier.classify(&make_input("thank you!"));
477        assert!(score < 0.1, "thanks should score very low, got {score}");
478    }
479
480    #[test]
481    fn korean_greeting() {
482        let classifier = HeuristicClassifier::new();
483        let score = classifier.classify(&make_input("안녕하세요"));
484        assert!(
485            score < 0.1,
486            "korean greeting should score very low, got {score}"
487        );
488    }
489
490    #[test]
491    fn short_question() {
492        let classifier = HeuristicClassifier::new();
493        let score = classifier.classify(&make_input("what is rust?"));
494        assert!(score < 0.1, "short question should score low, got {score}");
495    }
496
497    #[test]
498    fn japanese_short() {
499        let classifier = HeuristicClassifier::new();
500        let score = classifier.classify(&make_input("これ何?"));
501        assert!(score < 0.1, "japanese short should score low, got {score}");
502    }
503
504    // ── Medium-tier: moderate length, some structure ─────────────────
505
506    #[test]
507    fn medium_request() {
508        let classifier = HeuristicClassifier::new();
509        let score = classifier.classify(&make_input(
510            "Modify the config file to add the new endpoint for the auth service",
511        ));
512        assert!(
513            (0.05..0.35).contains(&score),
514            "medium request should score modest, got {score}"
515        );
516    }
517
518    #[test]
519    fn multi_line_request() {
520        let classifier = HeuristicClassifier::new();
521        let score = classifier.classify(&make_input(
522            "I need to update the following:\n- config file\n- router\n- middleware",
523        ));
524        assert!(
525            score > 0.1,
526            "multi-line should score higher than single line, got {score}"
527        );
528    }
529
530    // ── High-tier: long, code, files, technical ──────────────────────
531
532    #[test]
533    fn long_request_with_code_blocks() {
534        let classifier = HeuristicClassifier::new();
535        let score = classifier.classify(&make_input(
536            "Debug this error:\n```rust\nfn main() { panic!() }\n```\nThe stack trace shows a null pointer.",
537        ));
538        assert!(
539            score >= 0.25,
540            "code block request should score medium+, got {score}"
541        );
542    }
543
544    #[test]
545    fn multi_file_request() {
546        let classifier = HeuristicClassifier::new();
547        let score = classifier.classify(&make_input(
548            "Update src/main.rs and lib/config.rs to implement the new API",
549        ));
550        assert!(
551            score >= 0.15,
552            "multi-file should score medium+, got {score}"
553        );
554    }
555
556    #[test]
557    fn long_technical_request() {
558        let classifier = HeuristicClassifier::new();
559        let score = classifier.classify(&make_input(&format!(
560            "I need to implement a distributed event sourcing system with CQRS. \
561             The system should support: (1) event store with append-only log, \
562             (2) command bus with validation, (3) query side with materialized views, \
563             (4) saga orchestration for distributed transactions. \
564             Here's my current architecture:\n{}\nPlease review and suggest improvements.",
565            "x".repeat(200)
566        )));
567        assert!(
568            score >= 0.2,
569            "long technical request should score medium+, got {score}"
570        );
571    }
572
573    #[test]
574    fn high_symbol_density() {
575        let classifier = HeuristicClassifier::new();
576        let score = classifier.classify(&make_input(
577            r#"{"type": "router", "config": {"high": {"model": "opus"}, "low": {"model": "haiku"}}}"#,
578        ));
579        assert!(
580            score >= 0.15,
581            "json/config should score higher due to symbol density, got {score}"
582        );
583    }
584
585    // ── Context and turn influence ───────────────────────────────────
586
587    #[test]
588    fn large_context_boosts_score() {
589        let classifier = HeuristicClassifier::new();
590        let low = classifier.classify(&make_input_with_context("hello", 0, 0));
591        let high = classifier.classify(&make_input_with_context("hello", 30_000, 15));
592        assert!(
593            high > low,
594            "large context should boost score: {low} vs {high}"
595        );
596    }
597
598    #[test]
599    fn turn_count_increases_score() {
600        let classifier = HeuristicClassifier::new();
601        let low = classifier.classify(&make_input_with_context("update this", 0, 0));
602        let high = classifier.classify(&make_input_with_context("update this", 10_000, 12));
603        assert!(
604            high >= low,
605            "more turns+context should boost score: {low} vs {high}"
606        );
607    }
608
609    // ── Structural detection ─────────────────────────────────────────
610
611    #[test]
612    fn detect_file_paths() {
613        let input = make_input("Look at src/main.rs for the bug");
614        assert!(input.contains_file_paths());
615        assert_eq!(input.file_path_count(), 1);
616    }
617
618    #[test]
619    fn detect_multiple_file_paths() {
620        let input = make_input("Update src/main.rs and lib/config.rs");
621        assert_eq!(input.file_path_count(), 2);
622    }
623
624    #[test]
625    fn no_file_paths() {
626        let input = make_input("What is a closure?");
627        assert!(!input.contains_file_paths());
628    }
629
630    #[test]
631    fn detect_code_blocks() {
632        let input = make_input("Here is the code:\n```rust\nfn main() {}\n```");
633        assert!(input.contains_code_blocks());
634    }
635
636    #[test]
637    fn no_code_blocks() {
638        let input = make_input("Just a plain message");
639        assert!(!input.contains_code_blocks());
640    }
641
642    #[test]
643    fn detect_question() {
644        let input = make_input("what is this?");
645        assert!(input.is_question());
646    }
647
648    #[test]
649    fn detect_single_sentence() {
650        let input = make_input("hello world");
651        assert!(input.is_single_sentence());
652    }
653
654    #[test]
655    fn not_single_sentence() {
656        let input = make_input("hello\nworld");
657        assert!(!input.is_single_sentence());
658    }
659
660    #[test]
661    fn symbol_density_plain_text() {
662        let input = make_input("hello world this is a test");
663        assert!(input.symbol_density() < 0.05);
664    }
665
666    #[test]
667    fn symbol_density_code() {
668        let input = make_input("fn main() -> Result<Vec<String>> { Ok(vec![]) }");
669        let density = input.symbol_density();
670        assert!(
671            density > 0.10,
672            "code should have high symbol density, got {density}"
673        );
674    }
675
676    #[test]
677    fn line_count_single() {
678        let input = make_input("hello");
679        assert_eq!(input.line_count(), 1);
680    }
681
682    #[test]
683    fn line_count_multi() {
684        let input = make_input("line1\nline2\nline3");
685        assert_eq!(input.line_count(), 3);
686    }
687
688    // ── Score bounds ─────────────────────────────────────────────────
689
690    #[test]
691    fn score_always_in_bounds() {
692        let classifier = HeuristicClassifier::new();
693        let inputs = vec![
694            make_input(""),
695            make_input(&"x".repeat(10000)),
696            make_input_with_context("", 100_000, 100),
697            make_input("hello thanks 안녕 こんにちは"),
698            make_input("```python\nprint('hello')\n```"),
699        ];
700        for input in &inputs {
701            let score = classifier.classify(input);
702            assert!((0.0..=1.0).contains(&score), "score out of bounds: {score}");
703        }
704    }
705
706    // ── Language independence ─────────────────────────────────────────
707
708    #[test]
709    fn language_independence_short() {
710        let classifier = HeuristicClassifier::new();
711        // All short messages should score low regardless of language
712        let short_messages = vec!["hello", "안녕", "こんにちは", "你好", "Привет", "مرحبا"];
713        for msg in &short_messages {
714            let score = classifier.classify(&make_input(msg));
715            assert!(
716                score < 0.1,
717                "short message '{msg}' should score very low, got {score}"
718            );
719        }
720    }
721
722    #[test]
723    fn language_independence_long_with_code() {
724        let classifier = HeuristicClassifier::new();
725        // Long messages with code should score high regardless of surrounding text language
726        let messages = vec![
727            format!(
728                "Refactor this:\n```\nfn main() {{}}\n```\n{}",
729                "x".repeat(100)
730            ),
731            format!(
732                "이 코드를 수정해:\n```\nfn main() {{}}\n```\n{}",
733                "x".repeat(100)
734            ),
735            format!(
736                "このコードを修正:\n```\nfn main() {{}}\n```\n{}",
737                "x".repeat(100)
738            ),
739        ];
740        for msg in &messages {
741            let score = classifier.classify(&make_input(msg));
742            assert!(
743                score > 0.2,
744                "long+code message should score medium+, got {score}"
745            );
746        }
747    }
748
749    // ── LLM classifier stub ──────────────────────────────────────────
750
751    #[tokio::test]
752    async fn llm_classifier_no_model_configured() {
753        let classifier = LlmClassifier::new(None);
754        let input = make_input("test");
755        let result = classifier.classify(&input, 0.5).await;
756        assert!(result.is_err());
757    }
758
759    #[test]
760    fn llm_classifier_default() {
761        let classifier = LlmClassifier::default();
762        assert!(classifier.model.is_none());
763    }
764
765    // ── parse_tier_from_response ─────────────────────────────────────
766
767    #[test]
768    fn parse_high_response() {
769        let score = super::parse_tier_from_response("high", 0.5).unwrap();
770        assert!((score - 0.9).abs() < 1e-6);
771    }
772
773    #[test]
774    fn parse_medium_response() {
775        let score = super::parse_tier_from_response("medium", 0.5).unwrap();
776        assert!((score - 0.5).abs() < 1e-6);
777    }
778
779    #[test]
780    fn parse_low_response() {
781        let score = super::parse_tier_from_response("low", 0.5).unwrap();
782        assert!((score - 0.1).abs() < 1e-6);
783    }
784
785    #[test]
786    fn parse_case_insensitive() {
787        let score = super::parse_tier_from_response("HIGH", 0.5).unwrap();
788        assert!((score - 0.9).abs() < 1e-6);
789    }
790
791    #[test]
792    fn parse_with_extra_text() {
793        let score = super::parse_tier_from_response("I think this is high tier", 0.5).unwrap();
794        assert!((score - 0.9).abs() < 1e-6);
795    }
796
797    #[test]
798    fn parse_unparseable_falls_back() {
799        let score = super::parse_tier_from_response("maybe", 0.42).unwrap();
800        assert!((score - 0.42).abs() < 1e-6);
801    }
802
803    // ── build_classifier_prompt ──────────────────────────────────────
804
805    #[test]
806    fn prompt_contains_user_message() {
807        let input = make_input("Debug the authentication module");
808        let prompt = super::build_classifier_prompt(&input, 0.5);
809        assert!(prompt.contains("Debug the authentication module"));
810        assert!(prompt.contains("high"));
811        assert!(prompt.contains("medium"));
812        assert!(prompt.contains("low"));
813    }
814
815    #[test]
816    fn prompt_truncates_long_message() {
817        let input = make_input(&"x".repeat(600));
818        let prompt = super::build_classifier_prompt(&input, 0.5);
819        assert!(prompt.contains("..."));
820        assert!(prompt.len() < 1000);
821    }
822}