Skip to main content

lean_ctx/core/
intent_engine.rs

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    /// All task types in declaration order. The default (coding) intent
32    /// taxonomy; personas can override it (12.16).
33    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    // Multilingual stems (de/fr/es) match morphology-tolerant via prefix.
257    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
282/// Shared scoring loop for PHRASE_RULES (exact word match) and
283/// STEM_RULES (prefix match). Multi-word entries match the whole query.
284fn 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        // Text signals too weak (unknown wording or language)? Behavioral
618        // signals from the session outrank a blind Explore fallback (#591).
619        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    /// #591 acceptance: de/fr/es sentences per TaskType, ≥90% hit rate.
1039    #[test]
1040    fn classify_multilingual_table() {
1041        let table: &[(&str, TaskType)] = &[
1042            // German
1043            ("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            // French
1070            ("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            // Spanish
1078            ("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    /// #591 acceptance: German filler words must not pollute keywords.
1109    #[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    /// #591: with weak text confidence, session file patterns outrank the
1139    /// blind Explore fallback.
1140    #[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}