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, serde::Serialize)]
813pub struct IntentRoute {
814    pub dimension: IntentDimension,
815    pub confidence: f64,
816    pub reasoning: String,
817}
818
819pub fn route_intent(query: &str, classification: &TaskClassification) -> IntentRoute {
820    let dimension = match classification.task_type {
821        TaskType::Explore | TaskType::Debug => IntentDimension::What,
822        TaskType::Review | TaskType::FixBug | TaskType::Test => IntentDimension::How,
823        TaskType::Generate | TaskType::Refactor | TaskType::Deploy | TaskType::Config => {
824            IntentDimension::Do
825        }
826    };
827    let complexity = classify_complexity(query, classification);
828    let reasoning = format!(
829        "{}({}) + {}complexity",
830        classification.task_type.as_str(),
831        dimension.as_str(),
832        match complexity {
833            super::adaptive::TaskComplexity::Mechanical => "low ",
834            super::adaptive::TaskComplexity::Standard => "",
835            super::adaptive::TaskComplexity::Architectural => "high ",
836        }
837    );
838    IntentRoute {
839        dimension,
840        confidence: classification.confidence,
841        reasoning,
842    }
843}
844
845#[cfg(test)]
846pub mod tests {
847    use super::*;
848
849    #[test]
850    fn classify_fix_bug() {
851        let r = classify("fix the bug in entropy.rs where token_entropy returns NaN");
852        assert_eq!(r.task_type, TaskType::FixBug);
853        assert!(r.confidence > 0.5);
854        assert!(r.targets.iter().any(|t| t.contains("entropy.rs")));
855    }
856
857    #[test]
858    fn classify_generate() {
859        let r = classify("add a new function normalized_token_entropy to entropy.rs");
860        assert_eq!(r.task_type, TaskType::Generate);
861        assert!(r.confidence > 0.5);
862    }
863
864    #[test]
865    fn classify_refactor() {
866        let r = classify("refactor the compression pipeline to split into smaller modules");
867        assert_eq!(r.task_type, TaskType::Refactor);
868    }
869
870    #[test]
871    fn classify_explore() {
872        let r = classify("how does the session cache work?");
873        assert_eq!(r.task_type, TaskType::Explore);
874    }
875
876    #[test]
877    fn classify_debug() {
878        let r = classify("debug why the compression ratio drops for large files");
879        assert_eq!(r.task_type, TaskType::Debug);
880    }
881
882    #[test]
883    fn classify_test() {
884        let r = classify("write unit tests for the token_optimizer module");
885        assert_eq!(r.task_type, TaskType::Test);
886    }
887
888    #[test]
889    fn targets_extract_paths() {
890        let r = classify("fix entropy.rs and update core/mod.rs");
891        assert!(r.targets.iter().any(|t| t.contains("entropy.rs")));
892        assert!(r.targets.iter().any(|t| t.contains("core/mod.rs")));
893    }
894
895    #[test]
896    fn targets_extract_identifiers() {
897        let r = classify("refactor SessionCache to use LRU eviction");
898        assert!(r.targets.iter().any(|t| t == "SessionCache"));
899    }
900
901    #[test]
902    fn fallback_to_explore() {
903        let r = classify("xyz qqq bbb");
904        assert_eq!(r.task_type, TaskType::Explore);
905        assert!(r.confidence < 0.5);
906    }
907
908    #[test]
909    fn multi_intent_detection() {
910        let results = detect_multi_intent("fix the bug in auth.rs and then write unit tests");
911        assert!(results.len() >= 2);
912        assert_eq!(results[0].task_type, TaskType::FixBug);
913        assert_eq!(results[1].task_type, TaskType::Test);
914    }
915
916    #[test]
917    fn single_intent_no_split() {
918        let results = detect_multi_intent("fix the bug in auth.rs");
919        assert_eq!(results.len(), 1);
920        assert_eq!(results[0].task_type, TaskType::FixBug);
921    }
922
923    #[test]
924    fn complexity_mechanical() {
925        let r = classify("add a comment");
926        let c = classify_complexity("add a comment", &r);
927        assert_eq!(c, super::super::adaptive::TaskComplexity::Mechanical);
928    }
929
930    #[test]
931    fn complexity_architectural() {
932        let r = classify("refactor auth across all files and update the migration");
933        let c = classify_complexity(
934            "refactor auth across all files and update the migration",
935            &r,
936        );
937        assert_eq!(c, super::super::adaptive::TaskComplexity::Architectural);
938    }
939
940    #[test]
941    fn route_explore_is_what() {
942        let c = TaskClassification {
943            task_type: TaskType::Explore,
944            confidence: 0.8,
945            targets: vec![],
946            keywords: vec!["explore".into()],
947        };
948        let route = route_intent("explore the codebase", &c);
949        assert_eq!(route.dimension, IntentDimension::What);
950    }
951
952    #[test]
953    fn route_fixbug_is_how() {
954        let c = TaskClassification {
955            task_type: TaskType::FixBug,
956            confidence: 0.9,
957            targets: vec!["auth.rs".into()],
958            keywords: vec!["fix".into(), "bug".into()],
959        };
960        let route = route_intent("fix the null pointer bug in auth.rs", &c);
961        assert_eq!(route.dimension, IntentDimension::How);
962    }
963
964    #[test]
965    fn route_generate_is_do() {
966        let c = TaskClassification {
967            task_type: TaskType::Generate,
968            confidence: 0.85,
969            targets: vec![],
970            keywords: vec!["generate".into()],
971        };
972        let route = route_intent("generate a new module", &c);
973        assert_eq!(route.dimension, IntentDimension::Do);
974    }
975
976    #[test]
977    fn route_complexity_describes_context_without_model_advice() {
978        let c = TaskClassification {
979            task_type: TaskType::FixBug,
980            confidence: 0.8,
981            targets: vec!["auth.rs".into(), "middleware.rs".into()],
982            keywords: vec!["fix".into()],
983        };
984        let route = route_intent("fix auth across all files and update the migration", &c);
985        assert_eq!(route.dimension, IntentDimension::How);
986        assert!(route.reasoning.contains("high complexity"));
987        assert!(
988            serde_json::to_value(&route)
989                .unwrap()
990                .get("model_tier")
991                .is_none()
992        );
993    }
994
995    #[test]
996    fn route_low_confidence_preserves_classification() {
997        let c = TaskClassification {
998            task_type: TaskType::Explore,
999            confidence: 0.3,
1000            targets: vec![],
1001            keywords: vec![],
1002        };
1003        let route = route_intent("something vague", &c);
1004        assert_eq!(route.dimension, IntentDimension::What);
1005        assert_eq!(route.confidence, c.confidence);
1006    }
1007
1008    /// #591 acceptance: de/fr/es sentences per TaskType, ≥90% hit rate.
1009    #[test]
1010    fn classify_multilingual_table() {
1011        let table: &[(&str, TaskType)] = &[
1012            // German
1013            ("behebe den fehler in auth.rs", TaskType::FixBug),
1014            (
1015                "erstelle eine neue funktion für das datums-parsing",
1016                TaskType::Generate,
1017            ),
1018            ("räum die funktion auf", TaskType::Refactor),
1019            (
1020                "refaktorisiere das modul in kleinere teile",
1021                TaskType::Refactor,
1022            ),
1023            (
1024                "erkläre wie der session cache funktioniert",
1025                TaskType::Explore,
1026            ),
1027            ("schreibe tests für den parser", TaskType::Test),
1028            (
1029                "prüfe ob die validierung korrekt funktioniert",
1030                TaskType::Test,
1031            ),
1032            ("debugge warum der server abstürzt", TaskType::Debug),
1033            (
1034                "konfiguriere die umgebungsvariablen für den daemon",
1035                TaskType::Config,
1036            ),
1037            ("veröffentliche die neue version", TaskType::Deploy),
1038            ("überprüfe die änderungen vor dem merge", TaskType::Review),
1039            // French
1040            ("corrige le bug dans le parseur", TaskType::FixBug),
1041            ("ajoute une fonction de validation", TaskType::Generate),
1042            ("explique comment fonctionne le cache", TaskType::Explore),
1043            ("nettoie ce module pour le simplifier", TaskType::Refactor),
1044            ("vérifie que les tests passent", TaskType::Test),
1045            ("déploie la nouvelle version", TaskType::Deploy),
1046            ("pourquoi le serveur plante-t-il", TaskType::Explore),
1047            // Spanish
1048            ("corrige el error en el módulo de auth", TaskType::FixBug),
1049            ("crea una función para parsear fechas", TaskType::Generate),
1050            ("explica cómo funciona la caché", TaskType::Explore),
1051            ("agrega soporte para webhooks", TaskType::Generate),
1052            (
1053                "muestra dónde se define la configuración",
1054                TaskType::Explore,
1055            ),
1056            ("revisa este pull request", TaskType::Review),
1057            ("despliega la nueva versión", TaskType::Deploy),
1058            ("escribe pruebas para el parser", TaskType::Test),
1059        ];
1060
1061        let misses: Vec<String> = table
1062            .iter()
1063            .filter_map(|(query, expected)| {
1064                let got = classify(query).task_type;
1065                (got != *expected).then(|| format!("'{query}': want {expected:?}, got {got:?}"))
1066            })
1067            .collect();
1068
1069        let hit_rate = (table.len() - misses.len()) as f64 / table.len() as f64;
1070        assert!(
1071            hit_rate >= 0.9,
1072            "multilingual hit rate {:.0}% < 90%:\n{}",
1073            hit_rate * 100.0,
1074            misses.join("\n")
1075        );
1076    }
1077
1078    /// #591 acceptance: German filler words must not pollute keywords.
1079    #[test]
1080    fn keywords_filter_german_fillers() {
1081        let r = classify("bitte kannst du diese funktion aufräumen, ich möchte sauberen code");
1082        for filler in ["bitte", "kannst", "möchte", "diese"] {
1083            assert!(
1084                !r.keywords.iter().any(|k| k == filler),
1085                "filler '{filler}' leaked into keywords: {:?}",
1086                r.keywords
1087            );
1088        }
1089        assert!(r.keywords.iter().any(|k| k == "funktion"));
1090        assert_eq!(r.task_type, TaskType::Refactor);
1091    }
1092
1093    #[test]
1094    fn targets_filter_capitalized_german_fillers() {
1095        let r = classify("Bitte behebe den Fehler in der Konfiguration");
1096        assert!(!r.targets.iter().any(|t| t == "Bitte"), "{:?}", r.targets);
1097        assert_eq!(r.task_type, TaskType::FixBug);
1098    }
1099
1100    #[test]
1101    fn urgency_detects_german_markers() {
1102        let intent = StructuredIntent::from_query("dringend: behebe den absturz sofort");
1103        assert!(intent.urgency > 0.5);
1104        assert_eq!(intent.task_type, TaskType::FixBug);
1105        assert!(intent.format_header().contains("URGENT"));
1106    }
1107
1108    /// #591: with weak text confidence, session file patterns outrank the
1109    /// blind Explore fallback.
1110    #[test]
1111    fn behavioral_signals_beat_low_confidence_text() {
1112        let touched = vec![
1113            "tests/foo_test.rs".to_string(),
1114            "tests/bar_test.rs".to_string(),
1115        ];
1116        let intent = StructuredIntent::from_query_with_session("xyz qqq bbb", &touched);
1117        assert_eq!(intent.task_type, TaskType::Test);
1118        assert!((intent.confidence - 0.5).abs() < f64::EPSILON);
1119    }
1120
1121    #[test]
1122    fn german_refactor_gets_diff_output() {
1123        let intent = StructuredIntent::from_query("räum die funktion auf");
1124        assert_eq!(intent.task_type, TaskType::Refactor);
1125        assert_eq!(intent.task_type.output_format(), OutputFormat::DiffOnly);
1126    }
1127}