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 => {
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    // Multilingual stems (de/fr/es) match morphology-tolerant via prefix.
263    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
288/// Shared scoring loop for PHRASE_RULES (exact word match) and
289/// STEM_RULES (prefix match). Multi-word entries match the whole query.
290fn 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        // Text signals too weak (unknown wording or language)? Behavioral
624        // signals from the session outrank a blind Explore fallback (#591).
625        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    /// #591 acceptance: de/fr/es sentences per TaskType, ≥90% hit rate.
1045    #[test]
1046    fn classify_multilingual_table() {
1047        let table: &[(&str, TaskType)] = &[
1048            // German
1049            ("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            // French
1076            ("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            // Spanish
1084            ("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    /// #591 acceptance: German filler words must not pollute keywords.
1115    #[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    /// #591: with weak text confidence, session file patterns outrank the
1145    /// blind Explore fallback.
1146    #[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}