Skip to main content

clawft_core/pipeline/
classifier.rs

1//! Keyword classifier (Level 0 implementation).
2//!
3//! Classifies incoming chat requests by scanning the last user message
4//! for keyword patterns. This is the simplest possible classifier -- no
5//! ML, no embeddings, just case-insensitive substring matching.
6//!
7//! Keyword groups are checked in priority order; the first match wins.
8//! If no keywords match, the task defaults to [`TaskType::Chat`].
9
10use super::traits::{ChatRequest, TaskClassifier, TaskProfile, TaskType};
11
12/// A keyword pattern entry: a list of keywords and the task type they map to.
13struct KeywordPattern {
14    keywords: &'static [&'static str],
15    task_type: TaskType,
16}
17
18/// Static keyword patterns checked in priority order (first match wins).
19///
20/// Code-related patterns are checked before analysis because keywords like
21/// "fix" and "debug" are more specific than "explain" or "summarize".
22const PATTERNS: &[KeywordPattern] = &[
23    // Code generation (most specific -- check first)
24    KeywordPattern {
25        keywords: &[
26            "code",
27            "function",
28            "implement",
29            "debug",
30            "fix",
31            "program",
32            "script",
33            "compile",
34            "refactor",
35            "class",
36            "struct",
37            "module",
38        ],
39        task_type: TaskType::CodeGeneration,
40    },
41    // Code review
42    KeywordPattern {
43        keywords: &["review", "check", "audit", "lint", "inspect"],
44        task_type: TaskType::CodeReview,
45    },
46    // Research
47    KeywordPattern {
48        keywords: &[
49            "search", "find", "research", "look up", "lookup", "discover",
50        ],
51        task_type: TaskType::Research,
52    },
53    // Creative
54    KeywordPattern {
55        keywords: &[
56            "write",
57            "story",
58            "poem",
59            "creative",
60            "compose",
61            "draft",
62            "narrative",
63        ],
64        task_type: TaskType::Creative,
65    },
66    // Analysis
67    KeywordPattern {
68        keywords: &[
69            "analyze",
70            "explain",
71            "summarize",
72            "compare",
73            "evaluate",
74            "assess",
75        ],
76        task_type: TaskType::Analysis,
77    },
78    // Tool use
79    KeywordPattern {
80        keywords: &["use tool", "run tool", "execute", "call function"],
81        task_type: TaskType::ToolUse,
82    },
83];
84
85/// Level 0 keyword-based task classifier.
86///
87/// Scans the last user message for known keyword patterns and returns
88/// a [`TaskProfile`] with the detected type, estimated complexity, and
89/// matched keywords.
90///
91/// # Complexity heuristic
92///
93/// Complexity is estimated as the ratio of matched keywords to total words
94/// in the message, clamped to \[0.1, 0.9\]. More keyword hits = higher
95/// estimated complexity (the request is touching multiple concerns).
96pub struct KeywordClassifier;
97
98impl KeywordClassifier {
99    /// Create a new keyword classifier.
100    pub fn new() -> Self {
101        Self
102    }
103}
104
105impl Default for KeywordClassifier {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl TaskClassifier for KeywordClassifier {
112    fn classify(&self, request: &ChatRequest) -> TaskProfile {
113        // Extract the last user message content, lowercased.
114        let text = last_user_message(request);
115        let text_lower = text.to_lowercase();
116
117        // Scan patterns in priority order.
118        let mut matched_type = TaskType::Chat;
119        let mut matched_keywords: Vec<String> = Vec::new();
120
121        for pattern in PATTERNS {
122            let hits: Vec<String> = pattern
123                .keywords
124                .iter()
125                .filter(|kw| text_lower.contains(**kw))
126                .map(|kw| (*kw).to_string())
127                .collect();
128
129            if !hits.is_empty() && matched_keywords.is_empty() {
130                // First matching pattern determines the type.
131                matched_type = pattern.task_type.clone();
132                matched_keywords = hits;
133            } else if !hits.is_empty() {
134                // Additional matches from later patterns still count for complexity.
135                matched_keywords.extend(hits);
136            }
137        }
138
139        // Estimate complexity from keyword density.
140        let word_count = text_lower.split_whitespace().count().max(1) as f32;
141        let keyword_density = matched_keywords.len() as f32 / word_count;
142        let raw_complexity = keyword_density + request.complexity_boost;
143        let complexity = raw_complexity.clamp(0.1, 0.9);
144
145        TaskProfile {
146            task_type: matched_type,
147            complexity,
148            keywords: matched_keywords,
149        }
150    }
151}
152
153/// Extract the content of the last user message, or empty string if none.
154fn last_user_message(request: &ChatRequest) -> String {
155    request
156        .messages
157        .iter()
158        .rev()
159        .find(|m| m.role == "user")
160        .map(|m| m.content.clone())
161        .unwrap_or_default()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::pipeline::traits::LlmMessage;
168
169    fn make_request(user_content: &str) -> ChatRequest {
170        ChatRequest {
171            messages: vec![LlmMessage {
172                role: "user".into(),
173                content: user_content.into(),
174                tool_call_id: None,
175                tool_calls: None,
176            }],
177            tools: vec![],
178            model: None,
179            max_tokens: None,
180            temperature: None,
181            auth_context: None,
182            complexity_boost: 0.0,
183        }
184    }
185
186    fn make_request_with_system(system: &str, user: &str) -> ChatRequest {
187        ChatRequest {
188            messages: vec![
189                LlmMessage {
190                    role: "system".into(),
191                    content: system.into(),
192                    tool_call_id: None,
193                    tool_calls: None,
194                },
195                LlmMessage {
196                    role: "user".into(),
197                    content: user.into(),
198                    tool_call_id: None,
199                    tool_calls: None,
200                },
201            ],
202            tools: vec![],
203            model: None,
204            max_tokens: None,
205            temperature: None,
206            auth_context: None,
207            complexity_boost: 0.0,
208        }
209    }
210
211    #[test]
212    fn classify_code_generation() {
213        let classifier = KeywordClassifier::new();
214        let profile = classifier.classify(&make_request("Please implement a sorting function"));
215        assert_eq!(profile.task_type, TaskType::CodeGeneration);
216        assert!(profile.keywords.contains(&"implement".to_string()));
217        assert!(profile.keywords.contains(&"function".to_string()));
218    }
219
220    #[test]
221    fn classify_code_generation_debug() {
222        let classifier = KeywordClassifier::new();
223        let profile = classifier.classify(&make_request("Can you debug this code for me?"));
224        assert_eq!(profile.task_type, TaskType::CodeGeneration);
225        assert!(profile.keywords.contains(&"debug".to_string()));
226        assert!(profile.keywords.contains(&"code".to_string()));
227    }
228
229    #[test]
230    fn classify_code_review() {
231        let classifier = KeywordClassifier::new();
232        let profile = classifier.classify(&make_request("Please review my pull request"));
233        assert_eq!(profile.task_type, TaskType::CodeReview);
234        assert!(profile.keywords.contains(&"review".to_string()));
235    }
236
237    #[test]
238    fn classify_research() {
239        let classifier = KeywordClassifier::new();
240        let profile = classifier.classify(&make_request("Research the best database options"));
241        assert_eq!(profile.task_type, TaskType::Research);
242        assert!(profile.keywords.contains(&"research".to_string()));
243    }
244
245    #[test]
246    fn classify_research_lookup() {
247        let classifier = KeywordClassifier::new();
248        let profile = classifier.classify(&make_request("Can you look up the API docs?"));
249        assert_eq!(profile.task_type, TaskType::Research);
250        assert!(profile.keywords.contains(&"look up".to_string()));
251    }
252
253    #[test]
254    fn classify_creative() {
255        let classifier = KeywordClassifier::new();
256        let profile = classifier.classify(&make_request("Write me a short poem about Rust"));
257        assert_eq!(profile.task_type, TaskType::Creative);
258        assert!(profile.keywords.contains(&"poem".to_string()));
259    }
260
261    #[test]
262    fn classify_analysis() {
263        let classifier = KeywordClassifier::new();
264        let profile = classifier.classify(&make_request("Explain how tokio works"));
265        assert_eq!(profile.task_type, TaskType::Analysis);
266        assert!(profile.keywords.contains(&"explain".to_string()));
267    }
268
269    #[test]
270    fn classify_analysis_summarize() {
271        let classifier = KeywordClassifier::new();
272        let profile = classifier.classify(&make_request("Summarize this document for me"));
273        assert_eq!(profile.task_type, TaskType::Analysis);
274        assert!(profile.keywords.contains(&"summarize".to_string()));
275    }
276
277    #[test]
278    fn classify_tool_use() {
279        let classifier = KeywordClassifier::new();
280        let profile = classifier.classify(&make_request("Use tool to fetch the data"));
281        assert_eq!(profile.task_type, TaskType::ToolUse);
282        assert!(profile.keywords.contains(&"use tool".to_string()));
283    }
284
285    #[test]
286    fn classify_defaults_to_chat() {
287        let classifier = KeywordClassifier::new();
288        let profile = classifier.classify(&make_request("Hello, how are you today?"));
289        assert_eq!(profile.task_type, TaskType::Chat);
290        assert!(profile.keywords.is_empty());
291    }
292
293    #[test]
294    fn classify_empty_message() {
295        let classifier = KeywordClassifier::new();
296        let profile = classifier.classify(&make_request(""));
297        assert_eq!(profile.task_type, TaskType::Chat);
298        assert!(profile.keywords.is_empty());
299    }
300
301    #[test]
302    fn classify_no_user_messages() {
303        let classifier = KeywordClassifier::new();
304        let req = ChatRequest {
305            messages: vec![LlmMessage {
306                role: "system".into(),
307                content: "You are helpful. Implement code.".into(),
308                tool_call_id: None,
309                tool_calls: None,
310            }],
311            tools: vec![],
312            model: None,
313            max_tokens: None,
314            temperature: None,
315            auth_context: None,
316            complexity_boost: 0.0,
317        };
318        // Only the last user message is scanned; system messages are ignored.
319        let profile = classifier.classify(&req);
320        assert_eq!(profile.task_type, TaskType::Chat);
321    }
322
323    #[test]
324    fn classify_case_insensitive() {
325        let classifier = KeywordClassifier::new();
326        let profile = classifier.classify(&make_request("IMPLEMENT a FUNCTION in Rust"));
327        assert_eq!(profile.task_type, TaskType::CodeGeneration);
328    }
329
330    #[test]
331    fn classify_uses_last_user_message() {
332        let classifier = KeywordClassifier::new();
333        let req = ChatRequest {
334            messages: vec![
335                LlmMessage {
336                    role: "user".into(),
337                    content: "Research Rust crates".into(),
338                    tool_call_id: None,
339                    tool_calls: None,
340                },
341                LlmMessage {
342                    role: "assistant".into(),
343                    content: "Here are some crates...".into(),
344                    tool_call_id: None,
345                    tool_calls: None,
346                },
347                LlmMessage {
348                    role: "user".into(),
349                    content: "Now implement the function".into(),
350                    tool_call_id: None,
351                    tool_calls: None,
352                },
353            ],
354            tools: vec![],
355            model: None,
356            max_tokens: None,
357            temperature: None,
358            auth_context: None,
359            complexity_boost: 0.0,
360        };
361        let profile = classifier.classify(&req);
362        assert_eq!(profile.task_type, TaskType::CodeGeneration);
363    }
364
365    #[test]
366    fn classify_complexity_increases_with_keywords() {
367        let classifier = KeywordClassifier::new();
368
369        // Few keywords relative to message length
370        let short = classifier.classify(&make_request(
371            "Can you implement something for me in my big project today please?",
372        ));
373
374        // Many keywords relative to message length
375        let dense = classifier.classify(&make_request("implement debug fix code"));
376
377        assert!(
378            dense.complexity > short.complexity,
379            "dense={} should be > short={}",
380            dense.complexity,
381            short.complexity
382        );
383    }
384
385    #[test]
386    fn classify_complexity_clamped_min() {
387        let classifier = KeywordClassifier::new();
388        // Very long message with one keyword -- complexity should be at least 0.1
389        let profile = classifier.classify(&make_request(
390            "This is a very long message with lots of words but only one keyword: code. \
391             I am padding this out with many many more words to make the density very low.",
392        ));
393        assert!(
394            profile.complexity >= 0.1,
395            "complexity {} should be >= 0.1",
396            profile.complexity
397        );
398    }
399
400    #[test]
401    fn classify_complexity_clamped_max() {
402        let classifier = KeywordClassifier::new();
403        // All keywords, very dense
404        let profile = classifier.classify(&make_request("code function implement debug fix"));
405        assert!(
406            profile.complexity <= 0.9,
407            "complexity {} should be <= 0.9",
408            profile.complexity
409        );
410    }
411
412    #[test]
413    fn default_trait_impl() {
414        let classifier = KeywordClassifier;
415        let profile = classifier.classify(&make_request("hello"));
416        assert_eq!(profile.task_type, TaskType::Chat);
417    }
418
419    #[test]
420    fn classify_with_system_message_uses_user_only() {
421        let classifier = KeywordClassifier::new();
422        let profile = classifier.classify(&make_request_with_system(
423            "You are a code reviewer.",
424            "Hello, how is your day?",
425        ));
426        // "code" and "review" are in the system message, not the user message.
427        assert_eq!(profile.task_type, TaskType::Chat);
428    }
429
430    #[test]
431    fn classify_priority_code_over_analysis() {
432        let classifier = KeywordClassifier::new();
433        // "code" and "explain" both match, but code patterns are checked first.
434        let profile = classifier.classify(&make_request("Explain this code to me"));
435        assert_eq!(profile.task_type, TaskType::CodeGeneration);
436    }
437
438    // ── Complexity boost tests ──────────────────────────────────────
439
440    #[test]
441    fn complexity_boost_increases_complexity() {
442        let classifier = KeywordClassifier::new();
443        let baseline = classifier.classify(&make_request("hello world"));
444
445        let mut boosted_req = make_request("hello world");
446        boosted_req.complexity_boost = 0.3;
447        let boosted = classifier.classify(&boosted_req);
448
449        assert!(
450            boosted.complexity > baseline.complexity,
451            "boosted={} should be > baseline={}",
452            boosted.complexity,
453            baseline.complexity
454        );
455    }
456
457    #[test]
458    fn complexity_boost_clamps_at_max() {
459        let classifier = KeywordClassifier::new();
460        let mut req = make_request("code function implement debug fix");
461        req.complexity_boost = 0.5;
462        let profile = classifier.classify(&req);
463        assert!(
464            profile.complexity <= 0.9,
465            "complexity {} should be clamped at 0.9",
466            profile.complexity
467        );
468    }
469}