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