Skip to main content

reflex/semantic/
evaluator.rs

1//! Result evaluation for agentic query refinement
2//!
3//! This module evaluates query results to determine if they match user intent
4//! and provides feedback for query refinement if needed.
5
6use super::schema_agentic::{EvaluationIssue, EvaluationReport, IssueType};
7use crate::models::FileGroupedResult;
8
9/// Configuration for result evaluation
10#[derive(Debug, Clone)]
11pub struct EvaluationConfig {
12    /// Minimum number of results to consider successful (default: 1)
13    pub min_results: usize,
14
15    /// Maximum number of results before considering too broad (default: 1000)
16    pub max_results: usize,
17
18    /// Enable file type checking
19    pub check_file_types: bool,
20
21    /// Enable location checking
22    pub check_locations: bool,
23
24    /// Strictness level (0.0-1.0, higher is stricter)
25    pub strictness: f32,
26}
27
28impl Default for EvaluationConfig {
29    fn default() -> Self {
30        Self {
31            min_results: 1,
32            max_results: 1000,
33            check_file_types: true,
34            check_locations: true,
35            strictness: 0.5,
36        }
37    }
38}
39
40/// Evaluate query results and generate a report
41///
42/// This function checks for common issues like:
43/// - Empty results (query too specific)
44/// - Too many results (query too broad)
45/// - Results in unexpected file types or directories
46/// - Potential language or symbol type mismatches
47///
48/// # Parameters
49/// - `gathered_context`: Optional context from tools (if context gathering was used)
50/// - `num_queries`: Number of queries generated (0 = direct answer from context)
51/// - `confidence`: LLM confidence score (0.0-1.0) in the response
52pub fn evaluate_results(
53    results: &[FileGroupedResult],
54    total_count: usize,
55    user_question: &str,
56    config: &EvaluationConfig,
57    gathered_context: Option<&str>,
58    num_queries: usize,
59    confidence: Option<f32>,
60) -> EvaluationReport {
61    let mut issues = Vec::new();
62    let mut score = 1.0; // Start at perfect score, deduct for issues
63
64    // Check 1: Empty results - BUT check if this is intentional
65    if total_count == 0 {
66        // Case 1: No queries generated (LLM answered directly from context/metadata)
67        if num_queries == 0 {
68            // High confidence direct answer - this is GOOD, not an error
69            if confidence.unwrap_or(0.0) >= 0.90 {
70                // Perfect score - answer is in gathered context or metadata
71                score = 1.0;
72                // Don't add any issues - this is the correct behavior
73            } else {
74                // Lower confidence direct answer - still probably okay
75                issues.push(EvaluationIssue {
76                    issue_type: IssueType::EmptyResults,
77                    description: "No queries generated. Answer provided from available context."
78                        .to_string(),
79                    severity: 0.2, // Very low severity - likely intentional
80                });
81                score -= 0.2;
82            }
83        }
84        // Case 2: Queries were generated but found nothing - actual problem
85        else {
86            // Reduce severity if context was gathered (more forgiving)
87            let severity = if gathered_context.is_some() { 0.6 } else { 0.8 };
88            issues.push(EvaluationIssue {
89                issue_type: IssueType::EmptyResults,
90                description:
91                    "No results found. Query may be too specific or pattern may be incorrect."
92                        .to_string(),
93                severity,
94            });
95            score -= severity;
96        }
97    }
98    // Check 2: Too many results
99    else if total_count > config.max_results {
100        let severity = (total_count as f32 / config.max_results as f32 - 1.0).min(0.8);
101        issues.push(EvaluationIssue {
102            issue_type: IssueType::TooManyResults,
103            description: format!(
104                "Found {} results (max threshold: {}). Query may be too broad.",
105                total_count, config.max_results
106            ),
107            severity,
108        });
109        score -= severity;
110    }
111    // Check 3: Few results (warning, not failure)
112    else if total_count < config.min_results {
113        let severity = 0.3; // Lower severity for just below threshold
114        issues.push(EvaluationIssue {
115            issue_type: IssueType::EmptyResults,
116            description: format!(
117                "Only {} result(s) found. Consider broadening the search.",
118                total_count
119            ),
120            severity,
121        });
122        score -= severity;
123    }
124
125    // Check 4: File type consistency (if enabled)
126    if config.check_file_types && !results.is_empty() {
127        let file_type_issues = check_file_type_consistency(results, user_question);
128        score -= file_type_issues.iter().map(|i| i.severity).sum::<f32>();
129        issues.extend(file_type_issues);
130    }
131
132    // Check 5: Location consistency (if enabled)
133    if config.check_locations && !results.is_empty() {
134        let location_issues = check_location_patterns(results, user_question);
135        score -= location_issues.iter().map(|i| i.severity).sum::<f32>();
136        issues.extend(location_issues);
137    }
138
139    // Clamp score to [0.0, 1.0]
140    score = score.clamp(0.0, 1.0);
141
142    // Determine success based on score and strictness
143    let success_threshold = 0.4 + (config.strictness * 0.2);
144    let success = score >= success_threshold;
145
146    // Generate refinement suggestions
147    let suggestions = generate_suggestions(&issues, results, user_question);
148
149    EvaluationReport {
150        success,
151        issues,
152        suggestions,
153        score,
154    }
155}
156
157/// Check if file types in results match the expected types from the question
158fn check_file_type_consistency(
159    results: &[FileGroupedResult],
160    user_question: &str,
161) -> Vec<EvaluationIssue> {
162    let mut issues = Vec::new();
163    let question_lower = user_question.to_lowercase();
164
165    // Extract file extensions from results
166    let mut extensions: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
167    for result in results {
168        if let Some(ext) = std::path::Path::new(&result.path)
169            .extension()
170            .and_then(|e| e.to_str())
171        {
172            *extensions.entry(ext.to_lowercase()).or_insert(0) += 1;
173        }
174    }
175
176    // Check for language-specific keywords in question
177    let language_hints: Vec<(&str, Vec<&str>)> = vec![
178        ("rust", vec!["rs"]),
179        ("python", vec!["py"]),
180        ("typescript", vec!["ts", "tsx"]),
181        ("javascript", vec!["js", "jsx"]),
182        ("java", vec!["java"]),
183        ("go", vec!["go"]),
184        ("c++", vec!["cpp", "cc", "cxx", "hpp", "h"]),
185        ("c#", vec!["cs"]),
186        ("ruby", vec!["rb"]),
187        ("php", vec!["php"]),
188    ];
189
190    for (lang, expected_exts) in language_hints {
191        if question_lower.contains(lang) {
192            // Check if any results match expected extensions
193            let has_matching = expected_exts
194                .iter()
195                .any(|ext| extensions.contains_key(*ext));
196
197            if !has_matching && !results.is_empty() {
198                issues.push(EvaluationIssue {
199                    issue_type: IssueType::WrongFileTypes,
200                    description: format!(
201                        "Question mentions '{}' but results don't contain {} files. Found: {}",
202                        lang,
203                        expected_exts.join("/"),
204                        extensions
205                            .keys()
206                            .take(5)
207                            .map(|s| s.as_str())
208                            .collect::<Vec<_>>()
209                            .join(", ")
210                    ),
211                    severity: 0.3,
212                });
213            }
214        }
215    }
216
217    issues
218}
219
220/// Check if result locations match expected patterns from the question
221fn check_location_patterns(
222    results: &[FileGroupedResult],
223    user_question: &str,
224) -> Vec<EvaluationIssue> {
225    let mut issues = Vec::new();
226    let question_lower = user_question.to_lowercase();
227
228    // Common directory hints in questions
229    let dir_hints = vec![
230        ("test", vec!["test", "tests", "spec", "__tests__"]),
231        ("source", vec!["src", "lib", "app"]),
232        ("config", vec!["config", "conf", "settings"]),
233        ("util", vec!["util", "utils", "helper", "helpers"]),
234        ("api", vec!["api", "endpoint", "route"]),
235        ("model", vec!["model", "models", "entity", "entities"]),
236    ];
237
238    for (hint, expected_dirs) in dir_hints {
239        if question_lower.contains(hint) {
240            // Check if results contain expected directories
241            let has_matching = results.iter().any(|r| {
242                let path_lower = r.path.to_lowercase();
243                expected_dirs.iter().any(|dir| path_lower.contains(dir))
244            });
245
246            if !has_matching && results.len() > 3 {
247                // Only flag if we have several results but none in expected location
248                issues.push(EvaluationIssue {
249                    issue_type: IssueType::WrongLocations,
250                    description: format!(
251                        "Question mentions '{}' but results are not in typical directories ({})",
252                        hint,
253                        expected_dirs.join(", ")
254                    ),
255                    severity: 0.15, // Very low severity - just a hint
256                });
257            }
258        }
259    }
260
261    issues
262}
263
264/// Generate refinement suggestions based on issues
265fn generate_suggestions(
266    issues: &[EvaluationIssue],
267    _results: &[FileGroupedResult],
268    _user_question: &str,
269) -> Vec<String> {
270    let mut suggestions = Vec::new();
271
272    for issue in issues {
273        match issue.issue_type {
274            IssueType::EmptyResults => {
275                suggestions.push(
276                    "Try a broader search pattern (remove --exact, use --contains)".to_string(),
277                );
278                suggestions
279                    .push("Remove language or file filters to expand search scope".to_string());
280                suggestions.push("Check if the pattern spelling is correct".to_string());
281            }
282            IssueType::TooManyResults => {
283                suggestions.push("Add --symbols flag to find only definitions".to_string());
284                suggestions.push("Add --kind filter to narrow by symbol type".to_string());
285                suggestions.push("Add --lang or --glob filter to narrow file scope".to_string());
286                suggestions.push("Use more specific search pattern".to_string());
287            }
288            IssueType::WrongFileTypes => {
289                suggestions
290                    .push("Add --lang filter to search only relevant language files".to_string());
291                suggestions
292                    .push("Verify the language mentioned in question matches codebase".to_string());
293            }
294            IssueType::WrongLocations => {
295                suggestions.push(
296                    "Add --file or --glob filter to focus on specific directories".to_string(),
297                );
298            }
299            IssueType::WrongSymbolType => {
300                suggestions.push("Adjust --kind filter to match expected symbol type".to_string());
301                suggestions.push(
302                    "Remove --symbols flag to find usages instead of definitions".to_string(),
303                );
304            }
305            IssueType::WrongLanguage => {
306                suggestions
307                    .push("Review --lang filter and ensure it matches the codebase".to_string());
308            }
309        }
310    }
311
312    // Deduplicate suggestions
313    suggestions.sort();
314    suggestions.dedup();
315
316    // Limit to top 5 most relevant suggestions
317    suggestions.truncate(5);
318
319    suggestions
320}
321
322/// Format evaluation report for LLM consumption
323pub fn format_evaluation_for_llm(report: &EvaluationReport) -> String {
324    let mut output = Vec::new();
325
326    output.push("## Query Result Evaluation\n".to_string());
327    output.push(format!("**Success:** {}", report.success));
328    output.push(format!("**Score:** {:.2}/1.0\n", report.score));
329
330    if !report.issues.is_empty() {
331        output.push("### Issues Found:\n".to_string());
332        for (idx, issue) in report.issues.iter().enumerate() {
333            output.push(format!(
334                "{}. **{:?}** (severity: {:.2})",
335                idx + 1,
336                issue.issue_type,
337                issue.severity
338            ));
339            output.push(format!("   {}\n", issue.description));
340        }
341    }
342
343    if !report.suggestions.is_empty() {
344        output.push("\n### Refinement Suggestions:\n".to_string());
345        for (idx, suggestion) in report.suggestions.iter().enumerate() {
346            output.push(format!("{}. {}", idx + 1, suggestion));
347        }
348    }
349
350    output.join("\n")
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::models::{MatchResult, Span};
357
358    fn create_test_result(path: &str, line: usize) -> FileGroupedResult {
359        FileGroupedResult {
360            path: path.to_string(),
361            language: crate::models::Language::Unknown,
362            dependencies: None,
363            matches: vec![MatchResult {
364                kind: crate::models::SymbolKind::Unknown("test".to_string()),
365                symbol: None,
366                span: Span {
367                    start_line: line,
368                    end_line: line,
369                },
370                preview: "test preview".to_string(),
371                context_before: vec![],
372                context_after: vec![],
373            }],
374        }
375    }
376
377    #[test]
378    fn test_evaluate_empty_results() {
379        let config = EvaluationConfig::default();
380        let report = evaluate_results(&[], 0, "find todos", &config, None, 1, Some(0.9));
381
382        assert!(!report.success);
383        assert!(!report.issues.is_empty());
384        assert_eq!(report.issues[0].issue_type, IssueType::EmptyResults);
385        assert!(!report.suggestions.is_empty());
386    }
387
388    #[test]
389    fn test_evaluate_too_many_results() {
390        let config = EvaluationConfig::default();
391        let results = vec![create_test_result("test.rs", 1)];
392        let report = evaluate_results(&results, 2000, "find all", &config, None, 1, Some(0.9));
393
394        assert!(!report.success);
395        assert!(
396            report
397                .issues
398                .iter()
399                .any(|i| i.issue_type == IssueType::TooManyResults)
400        );
401    }
402
403    #[test]
404    fn test_evaluate_success() {
405        let config = EvaluationConfig::default();
406        let results = vec![
407            create_test_result("src/main.rs", 10),
408            create_test_result("src/lib.rs", 20),
409        ];
410        let report = evaluate_results(&results, 10, "find functions", &config, None, 1, Some(0.85));
411
412        assert!(report.success);
413        assert!(report.score > 0.7);
414    }
415
416    #[test]
417    fn test_check_file_type_consistency() {
418        let results = vec![create_test_result("test.py", 1)];
419        let issues = check_file_type_consistency(&results, "Find Rust functions");
420
421        assert!(!issues.is_empty());
422        assert_eq!(issues[0].issue_type, IssueType::WrongFileTypes);
423    }
424
425    #[test]
426    fn test_check_location_patterns() {
427        let results = vec![
428            create_test_result("src/main.rs", 1),
429            create_test_result("src/lib.rs", 2),
430            create_test_result("src/utils.rs", 3),
431            create_test_result("src/helper.rs", 4),
432        ];
433        let issues = check_location_patterns(&results, "Find test functions");
434
435        // Should suggest results should be in test directories
436        assert!(!issues.is_empty());
437        assert_eq!(issues[0].issue_type, IssueType::WrongLocations);
438    }
439
440    #[test]
441    fn test_generate_suggestions() {
442        let issues = vec![EvaluationIssue {
443            issue_type: IssueType::EmptyResults,
444            description: "No results".to_string(),
445            severity: 0.9,
446        }];
447
448        let suggestions = generate_suggestions(&issues, &[], "test");
449        assert!(!suggestions.is_empty());
450        assert!(suggestions.iter().any(|s| s.contains("broader")));
451    }
452
453    #[test]
454    fn test_evaluate_direct_answer_high_confidence() {
455        let config = EvaluationConfig::default();
456        let report = evaluate_results(&[], 0, "How many files?", &config, None, 0, Some(0.95));
457
458        assert!(report.success); // Should pass!
459        assert_eq!(report.score, 1.0); // Perfect score
460        assert!(report.issues.is_empty()); // No issues
461    }
462
463    #[test]
464    fn test_evaluate_direct_answer_low_confidence() {
465        let config = EvaluationConfig::default();
466        let report = evaluate_results(&[], 0, "How many files?", &config, None, 0, Some(0.75));
467
468        assert!(report.success); // Should still pass
469        assert!(report.score >= 0.7); // Decent score
470        assert_eq!(report.issues.len(), 1); // One low-severity issue
471        assert!(report.issues[0].severity < 0.3); // Low severity
472    }
473}