1use super::schema_agentic::{EvaluationIssue, EvaluationReport, IssueType};
7use crate::models::FileGroupedResult;
8
9#[derive(Debug, Clone)]
11pub struct EvaluationConfig {
12 pub min_results: usize,
14
15 pub max_results: usize,
17
18 pub check_file_types: bool,
20
21 pub check_locations: bool,
23
24 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
40pub 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; if total_count == 0 {
66 if num_queries == 0 {
68 if confidence.unwrap_or(0.0) >= 0.90 {
70 score = 1.0;
72 } else {
74 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, });
81 score -= 0.2;
82 }
83 }
84 else {
86 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 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 else if total_count < config.min_results {
113 let severity = 0.3; 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 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 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 score = score.clamp(0.0, 1.0);
141
142 let success_threshold = 0.4 + (config.strictness * 0.2);
144 let success = score >= success_threshold;
145
146 let suggestions = generate_suggestions(&issues, results, user_question);
148
149 EvaluationReport {
150 success,
151 issues,
152 suggestions,
153 score,
154 }
155}
156
157fn 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 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 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 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
220fn 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 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 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 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, });
257 }
258 }
259 }
260
261 issues
262}
263
264fn 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 suggestions.sort();
314 suggestions.dedup();
315
316 suggestions.truncate(5);
318
319 suggestions
320}
321
322pub 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 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); assert_eq!(report.score, 1.0); assert!(report.issues.is_empty()); }
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); assert!(report.score >= 0.7); assert_eq!(report.issues.len(), 1); assert!(report.issues[0].severity < 0.3); }
473}