1#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2pub enum TaskType {
3 Generate,
4 FixBug,
5 Refactor,
6 Explore,
7 Test,
8 Debug,
9 Config,
10 Deploy,
11 Review,
12}
13
14impl TaskType {
15 pub fn as_str(&self) -> &'static str {
16 match self {
17 Self::Generate => "generate",
18 Self::FixBug => "fix_bug",
19 Self::Refactor => "refactor",
20 Self::Explore => "explore",
21 Self::Test => "test",
22 Self::Debug => "debug",
23 Self::Config => "config",
24 Self::Deploy => "deploy",
25 Self::Review => "review",
26 }
27 }
28
29 pub fn all() -> &'static [TaskType] {
32 &[
33 Self::Generate,
34 Self::FixBug,
35 Self::Refactor,
36 Self::Explore,
37 Self::Test,
38 Self::Debug,
39 Self::Config,
40 Self::Deploy,
41 Self::Review,
42 ]
43 }
44
45 pub fn thinking_budget(&self) -> ThinkingBudget {
46 match self {
47 Self::Generate | Self::FixBug | Self::Test | Self::Config | Self::Deploy => {
48 ThinkingBudget::Minimal
49 }
50 Self::Refactor | Self::Explore | Self::Debug | Self::Review => ThinkingBudget::Medium,
51 }
52 }
53
54 pub fn output_format(&self) -> OutputFormat {
55 match self {
56 Self::Generate | Self::Test | Self::Config => OutputFormat::CodeOnly,
57 Self::FixBug | Self::Refactor => OutputFormat::DiffOnly,
58 Self::Explore | Self::Review => OutputFormat::ExplainConcise,
59 Self::Debug => OutputFormat::Trace,
60 Self::Deploy => OutputFormat::StepList,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ThinkingBudget {
67 Minimal,
68 Medium,
69 Trace,
70 Deep,
71}
72
73impl ThinkingBudget {
74 pub fn instruction(&self) -> &'static str {
75 match self {
76 Self::Minimal => "THINKING: Skip analysis. The task is clear — generate code directly.",
77 Self::Medium => "THINKING: 2-3 step analysis max. Identify what to change, then act. Do not over-analyze.",
78 Self::Trace => "THINKING: Short trace only. Identify root cause in 3 steps max, then generate fix.",
79 Self::Deep => "THINKING: Analyze structure and dependencies. Summarize findings concisely.",
80 }
81 }
82
83 pub fn suppresses_thinking(&self) -> bool {
84 matches!(self, Self::Minimal)
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum OutputFormat {
90 CodeOnly,
91 DiffOnly,
92 ExplainConcise,
93 Trace,
94 StepList,
95}
96
97impl OutputFormat {
98 pub fn instruction(&self) -> &'static str {
99 match self {
100 Self::CodeOnly => {
101 "OUTPUT-HINT: Prefer code blocks. Minimize prose unless user asks for explanation."
102 }
103 Self::DiffOnly => "OUTPUT-HINT: Prefer showing only changed lines as +/- diffs.",
104 Self::ExplainConcise => "OUTPUT-HINT: Brief summary, then code/data if relevant.",
105 Self::Trace => "OUTPUT-HINT: Show cause→effect chain with code references.",
106 Self::StepList => "OUTPUT-HINT: Numbered action list, one step at a time.",
107 }
108 }
109}
110
111#[derive(Debug)]
112pub struct TaskClassification {
113 pub task_type: TaskType,
114 pub confidence: f64,
115 pub targets: Vec<String>,
116 pub keywords: Vec<String>,
117}
118
119const PHRASE_RULES: &[(&[&str], TaskType, f64)] = &[
120 (
121 &[
122 "add",
123 "create",
124 "implement",
125 "build",
126 "write",
127 "generate",
128 "make",
129 "new feature",
130 "new",
131 ],
132 TaskType::Generate,
133 0.9,
134 ),
135 (
136 &[
137 "fix",
138 "bug",
139 "broken",
140 "crash",
141 "error in",
142 "not working",
143 "fails",
144 "wrong output",
145 ],
146 TaskType::FixBug,
147 0.95,
148 ),
149 (
150 &[
151 "refactor",
152 "clean up",
153 "restructure",
154 "rename",
155 "move",
156 "extract",
157 "simplify",
158 "split",
159 ],
160 TaskType::Refactor,
161 0.9,
162 ),
163 (
164 &[
165 "how",
166 "what",
167 "where",
168 "explain",
169 "understand",
170 "show me",
171 "describe",
172 "why does",
173 ],
174 TaskType::Explore,
175 0.85,
176 ),
177 (
178 &[
179 "test",
180 "spec",
181 "coverage",
182 "assert",
183 "unit test",
184 "integration test",
185 "mock",
186 ],
187 TaskType::Test,
188 0.9,
189 ),
190 (
191 &[
192 "debug",
193 "trace",
194 "inspect",
195 "log",
196 "breakpoint",
197 "step through",
198 "stack trace",
199 ],
200 TaskType::Debug,
201 0.9,
202 ),
203 (
204 &[
205 "config",
206 "setup",
207 "install",
208 "env",
209 "configure",
210 "settings",
211 "dotenv",
212 ],
213 TaskType::Config,
214 0.85,
215 ),
216 (
217 &[
218 "deploy", "release", "publish", "ship", "ci/cd", "pipeline", "docker",
219 ],
220 TaskType::Deploy,
221 0.85,
222 ),
223 (
224 &[
225 "review",
226 "check",
227 "audit",
228 "look at",
229 "evaluate",
230 "assess",
231 "pr review",
232 ],
233 TaskType::Review,
234 0.8,
235 ),
236];
237
238pub fn classify(query: &str) -> TaskClassification {
239 let q = query.to_lowercase();
240 let words: Vec<&str> = q.split_whitespace().collect();
241
242 let mut best_type = TaskType::Explore;
243 let mut best_score = 0.0_f64;
244
245 for &(phrases, task_type, base_confidence) in PHRASE_RULES {
246 let mut match_count = 0usize;
247 for phrase in phrases {
248 if phrase.contains(' ') {
249 if q.contains(phrase) {
250 match_count += 2;
251 }
252 } else if words.contains(phrase) {
253 match_count += 1;
254 }
255 }
256 if match_count > 0 {
257 let score = base_confidence * (match_count as f64).min(2.0) / 2.0;
258 if score > best_score {
259 best_score = score;
260 best_type = task_type;
261 }
262 }
263 }
264
265 let targets = extract_targets(query);
266 let keywords = extract_keywords(&q);
267
268 if best_score < 0.1 {
269 best_type = TaskType::Explore;
270 best_score = 0.3;
271 }
272
273 TaskClassification {
274 task_type: best_type,
275 confidence: best_score,
276 targets,
277 keywords,
278 }
279}
280
281fn extract_targets(query: &str) -> Vec<String> {
282 let mut targets = Vec::new();
283
284 for word in query.split_whitespace() {
285 if word.contains('.') && !word.starts_with('.') {
286 let clean = word.trim_matches(|c: char| {
287 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-'
288 });
289 if looks_like_path(clean) {
290 targets.push(clean.to_string());
291 }
292 }
293 if word.contains('/') && !word.starts_with("//") && !word.starts_with("http") {
294 let clean = word.trim_matches(|c: char| {
295 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-'
296 });
297 if clean.len() > 2 {
298 targets.push(clean.to_string());
299 }
300 }
301 }
302
303 for word in query.split_whitespace() {
304 let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
305 if w.contains('_') && w.len() > 3 && !targets.contains(&w.to_string()) {
306 targets.push(w.to_string());
307 }
308 if w.chars().any(char::is_uppercase)
309 && w.len() > 2
310 && !is_stop_word(w)
311 && !targets.contains(&w.to_string())
312 {
313 targets.push(w.to_string());
314 }
315 }
316
317 targets.truncate(5);
318 targets
319}
320
321fn looks_like_path(s: &str) -> bool {
322 let exts = [
323 ".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".toml", ".yaml", ".yml", ".json", ".md",
324 ];
325 exts.iter().any(|ext| s.ends_with(ext)) || s.contains('/')
326}
327
328fn is_stop_word(w: &str) -> bool {
329 matches!(
330 w.to_lowercase().as_str(),
331 "the"
332 | "this"
333 | "that"
334 | "with"
335 | "from"
336 | "into"
337 | "have"
338 | "please"
339 | "could"
340 | "would"
341 | "should"
342 | "also"
343 | "just"
344 | "then"
345 | "when"
346 | "what"
347 | "where"
348 | "which"
349 | "there"
350 | "here"
351 | "these"
352 | "those"
353 | "does"
354 | "will"
355 | "shall"
356 | "can"
357 | "may"
358 | "must"
359 | "need"
360 | "want"
361 | "like"
362 | "make"
363 | "take"
364 )
365}
366
367fn extract_keywords(query: &str) -> Vec<String> {
368 query
369 .split_whitespace()
370 .filter(|w| w.len() > 3)
371 .filter(|w| !is_stop_word(w))
372 .map(|w| {
373 w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
374 .to_lowercase()
375 })
376 .filter(|w| !w.is_empty())
377 .take(8)
378 .collect()
379}
380
381pub fn classify_complexity(
382 query: &str,
383 classification: &TaskClassification,
384) -> super::adaptive::TaskComplexity {
385 use super::adaptive::TaskComplexity;
386
387 let q = query.to_lowercase();
388 let word_count = q.split_whitespace().count();
389 let target_count = classification.targets.len();
390
391 let has_multi_file = target_count >= 3;
392 let has_cross_cutting = q.contains("all files")
393 || q.contains("across")
394 || q.contains("everywhere")
395 || q.contains("every")
396 || q.contains("migration")
397 || q.contains("architecture");
398
399 let is_simple = word_count < 8
400 && target_count <= 1
401 && matches!(
402 classification.task_type,
403 TaskType::Generate | TaskType::Config
404 );
405
406 if is_simple {
407 TaskComplexity::Mechanical
408 } else if has_multi_file || has_cross_cutting {
409 TaskComplexity::Architectural
410 } else {
411 TaskComplexity::Standard
412 }
413}
414
415pub fn detect_multi_intent(query: &str) -> Vec<TaskClassification> {
416 let delimiters = [" and then ", " then ", " also ", " + ", ". "];
417
418 let mut parts: Vec<&str> = vec![query];
419 for delim in &delimiters {
420 let mut new_parts = Vec::new();
421 for part in &parts {
422 for sub in part.split(delim) {
423 let trimmed = sub.trim();
424 if !trimmed.is_empty() {
425 new_parts.push(trimmed);
426 }
427 }
428 }
429 parts = new_parts;
430 }
431
432 if parts.len() <= 1 {
433 return vec![classify(query)];
434 }
435
436 parts.iter().map(|part| classify(part)).collect()
437}
438
439pub fn format_briefing_header(classification: &TaskClassification) -> String {
440 format!(
441 "[TASK:{} CONF:{:.0}% TARGETS:{} KW:{}]",
442 classification.task_type.as_str(),
443 classification.confidence * 100.0,
444 if classification.targets.is_empty() {
445 "-".to_string()
446 } else {
447 classification.targets.join(",")
448 },
449 classification.keywords.join(","),
450 )
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
454pub enum IntentScope {
455 SingleFile,
456 MultiFile,
457 CrossModule,
458 ProjectWide,
459}
460
461#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
462pub struct StructuredIntent {
463 pub task_type: TaskType,
464 pub confidence: f64,
465 pub targets: Vec<String>,
466 pub keywords: Vec<String>,
467 pub scope: IntentScope,
468 pub language_hint: Option<String>,
469 pub urgency: f64,
470 pub action_verb: Option<String>,
471}
472
473impl StructuredIntent {
474 pub fn from_query(query: &str) -> Self {
475 let classification = classify(query);
476 let complexity = classify_complexity(query, &classification);
477 let file_targets = classification
478 .targets
479 .iter()
480 .filter(|t| t.contains('.') || t.contains('/'))
481 .count();
482 let scope = match complexity {
483 super::adaptive::TaskComplexity::Mechanical => IntentScope::SingleFile,
484 super::adaptive::TaskComplexity::Standard => {
485 if file_targets > 1 {
486 IntentScope::MultiFile
487 } else {
488 IntentScope::SingleFile
489 }
490 }
491 super::adaptive::TaskComplexity::Architectural => {
492 let q = query.to_lowercase();
493 if q.contains("all files") || q.contains("everywhere") || q.contains("migration") {
494 IntentScope::ProjectWide
495 } else {
496 IntentScope::CrossModule
497 }
498 }
499 };
500
501 let language_hint = detect_language_hint(query, &classification.targets);
502 let urgency = detect_urgency(query);
503 let action_verb = extract_action_verb(query);
504
505 StructuredIntent {
506 task_type: classification.task_type,
507 confidence: classification.confidence,
508 targets: classification.targets,
509 keywords: classification.keywords,
510 scope,
511 language_hint,
512 urgency,
513 action_verb,
514 }
515 }
516
517 pub fn from_file_patterns(touched_files: &[String]) -> Self {
518 if touched_files.is_empty() {
519 return Self {
520 task_type: TaskType::Explore,
521 confidence: 0.3,
522 targets: Vec::new(),
523 keywords: Vec::new(),
524 scope: IntentScope::SingleFile,
525 language_hint: None,
526 urgency: 0.0,
527 action_verb: None,
528 };
529 }
530
531 let has_tests = touched_files
532 .iter()
533 .any(|f| f.contains("test") || f.contains("spec"));
534 let has_config = touched_files.iter().any(|f| {
535 let p = std::path::Path::new(f.as_str());
536 let is_config_ext = p.extension().is_some_and(|e| {
537 e.eq_ignore_ascii_case("toml")
538 || e.eq_ignore_ascii_case("yaml")
539 || e.eq_ignore_ascii_case("yml")
540 || e.eq_ignore_ascii_case("json")
541 });
542 is_config_ext || f.contains("config") || f.contains(".env")
543 });
544
545 let dirs: std::collections::HashSet<&str> = touched_files
546 .iter()
547 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
548 .collect();
549
550 let task_type = if has_tests && touched_files.len() <= 3 {
551 TaskType::Test
552 } else if has_config && touched_files.len() <= 2 {
553 TaskType::Config
554 } else if dirs.len() > 3 {
555 TaskType::Refactor
556 } else {
557 TaskType::Explore
558 };
559
560 let scope = match touched_files.len() {
561 1 => IntentScope::SingleFile,
562 2..=4 => IntentScope::MultiFile,
563 _ => IntentScope::CrossModule,
564 };
565
566 let language_hint = detect_language_from_files(touched_files);
567
568 Self {
569 task_type,
570 confidence: 0.5,
571 targets: touched_files.to_vec(),
572 keywords: Vec::new(),
573 scope,
574 language_hint,
575 urgency: 0.0,
576 action_verb: None,
577 }
578 }
579
580 pub fn from_query_with_session(query: &str, touched_files: &[String]) -> Self {
581 let mut intent = Self::from_query(query);
582
583 if intent.language_hint.is_none() && !touched_files.is_empty() {
584 intent.language_hint = detect_language_from_files(touched_files);
585 }
586
587 if intent.scope == IntentScope::SingleFile && touched_files.len() > 3 {
588 let dirs: std::collections::HashSet<&str> = touched_files
589 .iter()
590 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
591 .collect();
592 if dirs.len() > 2 {
593 intent.scope = IntentScope::MultiFile;
594 }
595 }
596
597 intent
598 }
599
600 pub fn format_header(&self) -> String {
601 format!(
602 "[TASK:{} SCOPE:{} CONF:{:.0}%{}{}]",
603 self.task_type.as_str(),
604 match self.scope {
605 IntentScope::SingleFile => "single",
606 IntentScope::MultiFile => "multi",
607 IntentScope::CrossModule => "cross",
608 IntentScope::ProjectWide => "project",
609 },
610 self.confidence * 100.0,
611 self.language_hint
612 .as_ref()
613 .map(|l| format!(" LANG:{l}"))
614 .unwrap_or_default(),
615 if self.urgency > 0.5 { " URGENT" } else { "" },
616 )
617 }
618}
619
620fn detect_language_hint(query: &str, targets: &[String]) -> Option<String> {
621 for t in targets {
622 let ext = std::path::Path::new(t).extension().and_then(|e| e.to_str());
623 match ext {
624 Some("rs") => return Some("rust".into()),
625 Some("ts" | "tsx") => return Some("typescript".into()),
626 Some("js" | "jsx") => return Some("javascript".into()),
627 Some("py") => return Some("python".into()),
628 Some("go") => return Some("go".into()),
629 Some("rb") => return Some("ruby".into()),
630 Some("java") => return Some("java".into()),
631 Some("swift") => return Some("swift".into()),
632 Some("zig") => return Some("zig".into()),
633 _ => {}
634 }
635 }
636
637 let q = query.to_lowercase();
638 let lang_keywords: &[(&str, &str)] = &[
639 ("rust", "rust"),
640 ("python", "python"),
641 ("typescript", "typescript"),
642 ("javascript", "javascript"),
643 ("golang", "go"),
644 (" go ", "go"),
645 ("ruby", "ruby"),
646 ("java ", "java"),
647 ("swift", "swift"),
648 ];
649 for &(kw, lang) in lang_keywords {
650 if q.contains(kw) {
651 return Some(lang.into());
652 }
653 }
654
655 None
656}
657
658fn detect_language_from_files(files: &[String]) -> Option<String> {
659 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
660 for f in files {
661 let ext = std::path::Path::new(f)
662 .extension()
663 .and_then(|e| e.to_str())
664 .unwrap_or("");
665 let lang = match ext {
666 "rs" => "rust",
667 "ts" | "tsx" => "typescript",
668 "js" | "jsx" => "javascript",
669 "py" => "python",
670 "go" => "go",
671 "rb" => "ruby",
672 "java" => "java",
673 _ => continue,
674 };
675 *counts.entry(lang).or_insert(0) += 1;
676 }
677 counts
678 .into_iter()
679 .max_by_key(|(_, c)| *c)
680 .map(|(l, _)| l.to_string())
681}
682
683fn detect_urgency(query: &str) -> f64 {
684 let q = query.to_lowercase();
685 let urgent_words = [
686 "urgent",
687 "asap",
688 "immediately",
689 "critical",
690 "hotfix",
691 "emergency",
692 "blocker",
693 "breaking",
694 ];
695 let hits = urgent_words.iter().filter(|w| q.contains(*w)).count();
696 (hits as f64 * 0.4).min(1.0)
697}
698
699fn extract_action_verb(query: &str) -> Option<String> {
700 let verbs = [
701 "fix",
702 "add",
703 "create",
704 "implement",
705 "refactor",
706 "debug",
707 "test",
708 "write",
709 "update",
710 "remove",
711 "delete",
712 "rename",
713 "move",
714 "extract",
715 "split",
716 "merge",
717 "deploy",
718 "review",
719 "check",
720 "build",
721 "generate",
722 "optimize",
723 "clean",
724 ];
725 let q = query.to_lowercase();
726 let words: Vec<&str> = q.split_whitespace().collect();
727 for v in &verbs {
728 if words.first() == Some(v) || words.get(1) == Some(v) {
729 return Some(v.to_string());
730 }
731 }
732 for v in &verbs {
733 if words.contains(v) {
734 return Some(v.to_string());
735 }
736 }
737 None
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
741pub enum IntentDimension {
742 What,
743 How,
744 Do,
745}
746
747impl IntentDimension {
748 pub fn as_str(&self) -> &'static str {
749 match self {
750 Self::What => "what",
751 Self::How => "how",
752 Self::Do => "do",
753 }
754 }
755}
756
757#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
758pub enum ModelTier {
759 Fast,
760 Standard,
761 Premium,
762}
763
764impl ModelTier {
765 pub fn as_str(&self) -> &'static str {
766 match self {
767 Self::Fast => "fast",
768 Self::Standard => "standard",
769 Self::Premium => "premium",
770 }
771 }
772}
773
774#[derive(Debug, Clone, serde::Serialize)]
775pub struct IntentRoute {
776 pub dimension: IntentDimension,
777 pub model_tier: ModelTier,
778 pub confidence: f64,
779 pub reasoning: String,
780}
781
782pub fn route_intent(query: &str, classification: &TaskClassification) -> IntentRoute {
783 let (base_dimension, base_tier) = match classification.task_type {
784 TaskType::Explore | TaskType::Debug => (IntentDimension::What, ModelTier::Fast),
785 TaskType::Review | TaskType::FixBug | TaskType::Test => {
786 (IntentDimension::How, ModelTier::Standard)
787 }
788 TaskType::Generate | TaskType::Refactor | TaskType::Deploy | TaskType::Config => {
789 (IntentDimension::Do, ModelTier::Premium)
790 }
791 };
792
793 let complexity = classify_complexity(query, classification);
794 let tier = match complexity {
795 super::adaptive::TaskComplexity::Architectural => {
796 if base_tier == ModelTier::Fast {
797 ModelTier::Standard
798 } else {
799 ModelTier::Premium
800 }
801 }
802 _ => base_tier,
803 };
804
805 let tier = if classification.confidence < 0.5 {
806 ModelTier::Standard
807 } else {
808 tier
809 };
810
811 let reasoning = format!(
812 "{}({}) + {}complexity -> {}",
813 classification.task_type.as_str(),
814 base_dimension.as_str(),
815 match complexity {
816 super::adaptive::TaskComplexity::Mechanical => "low ",
817 super::adaptive::TaskComplexity::Standard => "",
818 super::adaptive::TaskComplexity::Architectural => "high ",
819 },
820 tier.as_str()
821 );
822
823 IntentRoute {
824 dimension: base_dimension,
825 model_tier: tier,
826 confidence: classification.confidence,
827 reasoning,
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 #[test]
836 fn classify_fix_bug() {
837 let r = classify("fix the bug in entropy.rs where token_entropy returns NaN");
838 assert_eq!(r.task_type, TaskType::FixBug);
839 assert!(r.confidence > 0.5);
840 assert!(r.targets.iter().any(|t| t.contains("entropy.rs")));
841 }
842
843 #[test]
844 fn classify_generate() {
845 let r = classify("add a new function normalized_token_entropy to entropy.rs");
846 assert_eq!(r.task_type, TaskType::Generate);
847 assert!(r.confidence > 0.5);
848 }
849
850 #[test]
851 fn classify_refactor() {
852 let r = classify("refactor the compression pipeline to split into smaller modules");
853 assert_eq!(r.task_type, TaskType::Refactor);
854 }
855
856 #[test]
857 fn classify_explore() {
858 let r = classify("how does the session cache work?");
859 assert_eq!(r.task_type, TaskType::Explore);
860 }
861
862 #[test]
863 fn classify_debug() {
864 let r = classify("debug why the compression ratio drops for large files");
865 assert_eq!(r.task_type, TaskType::Debug);
866 }
867
868 #[test]
869 fn classify_test() {
870 let r = classify("write unit tests for the token_optimizer module");
871 assert_eq!(r.task_type, TaskType::Test);
872 }
873
874 #[test]
875 fn targets_extract_paths() {
876 let r = classify("fix entropy.rs and update core/mod.rs");
877 assert!(r.targets.iter().any(|t| t.contains("entropy.rs")));
878 assert!(r.targets.iter().any(|t| t.contains("core/mod.rs")));
879 }
880
881 #[test]
882 fn targets_extract_identifiers() {
883 let r = classify("refactor SessionCache to use LRU eviction");
884 assert!(r.targets.iter().any(|t| t == "SessionCache"));
885 }
886
887 #[test]
888 fn fallback_to_explore() {
889 let r = classify("xyz qqq bbb");
890 assert_eq!(r.task_type, TaskType::Explore);
891 assert!(r.confidence < 0.5);
892 }
893
894 #[test]
895 fn multi_intent_detection() {
896 let results = detect_multi_intent("fix the bug in auth.rs and then write unit tests");
897 assert!(results.len() >= 2);
898 assert_eq!(results[0].task_type, TaskType::FixBug);
899 assert_eq!(results[1].task_type, TaskType::Test);
900 }
901
902 #[test]
903 fn single_intent_no_split() {
904 let results = detect_multi_intent("fix the bug in auth.rs");
905 assert_eq!(results.len(), 1);
906 assert_eq!(results[0].task_type, TaskType::FixBug);
907 }
908
909 #[test]
910 fn complexity_mechanical() {
911 let r = classify("add a comment");
912 let c = classify_complexity("add a comment", &r);
913 assert_eq!(c, super::super::adaptive::TaskComplexity::Mechanical);
914 }
915
916 #[test]
917 fn complexity_architectural() {
918 let r = classify("refactor auth across all files and update the migration");
919 let c = classify_complexity(
920 "refactor auth across all files and update the migration",
921 &r,
922 );
923 assert_eq!(c, super::super::adaptive::TaskComplexity::Architectural);
924 }
925
926 #[test]
927 fn route_explore_is_what() {
928 let c = TaskClassification {
929 task_type: TaskType::Explore,
930 confidence: 0.8,
931 targets: vec![],
932 keywords: vec!["explore".into()],
933 };
934 let route = route_intent("explore the codebase", &c);
935 assert_eq!(route.dimension, IntentDimension::What);
936 assert_eq!(route.model_tier, ModelTier::Fast);
937 }
938
939 #[test]
940 fn route_fixbug_is_how() {
941 let c = TaskClassification {
942 task_type: TaskType::FixBug,
943 confidence: 0.9,
944 targets: vec!["auth.rs".into()],
945 keywords: vec!["fix".into(), "bug".into()],
946 };
947 let route = route_intent("fix the null pointer bug in auth.rs", &c);
948 assert_eq!(route.dimension, IntentDimension::How);
949 assert_eq!(route.model_tier, ModelTier::Standard);
950 }
951
952 #[test]
953 fn route_generate_is_do() {
954 let c = TaskClassification {
955 task_type: TaskType::Generate,
956 confidence: 0.85,
957 targets: vec![],
958 keywords: vec!["generate".into()],
959 };
960 let route = route_intent("generate a new module", &c);
961 assert_eq!(route.dimension, IntentDimension::Do);
962 assert_eq!(route.model_tier, ModelTier::Premium);
963 }
964
965 #[test]
966 fn route_complex_upgrades_tier() {
967 let c = TaskClassification {
968 task_type: TaskType::FixBug,
969 confidence: 0.8,
970 targets: vec!["auth.rs".into(), "middleware.rs".into()],
971 keywords: vec!["fix".into()],
972 };
973 let route = route_intent("fix auth across all files and update the migration", &c);
974 assert_eq!(route.model_tier, ModelTier::Premium);
975 }
976
977 #[test]
978 fn route_low_confidence_standard() {
979 let c = TaskClassification {
980 task_type: TaskType::Explore,
981 confidence: 0.3,
982 targets: vec![],
983 keywords: vec![],
984 };
985 let route = route_intent("something vague", &c);
986 assert_eq!(route.model_tier, ModelTier::Standard);
987 }
988}