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