Skip to main content

reflex/semantic/
answer.rs

1//! Answer generation from search results
2//!
3//! This module provides functionality to synthesize conversational answers
4//! from code search results using LLM providers.
5
6use anyhow::Result;
7use crate::models::FileGroupedResult;
8use super::providers::LlmProvider;
9
10/// Maximum number of matches to include in the prompt (to avoid token limits)
11const MAX_MATCHES_IN_PROMPT: usize = 50;
12
13/// Maximum preview length per match (characters)
14const MAX_PREVIEW_LENGTH: usize = 200;
15
16/// Generate a conversational answer based on search results
17///
18/// Takes the user's original question and search results, then calls the LLM
19/// to synthesize a natural language answer that references specific files and
20/// line numbers from the results.
21///
22/// # Arguments
23///
24/// * `question` - The original user question
25/// * `results` - Search results grouped by file
26/// * `total_count` - Total number of matches found
27/// * `gathered_context` - Optional context gathered from tools (documentation, codebase structure)
28/// * `codebase_context` - Optional codebase metadata (always available, language distribution, directories)
29/// * `provider` - LLM provider to use for answer generation
30///
31/// # Returns
32///
33/// A conversational answer string that summarizes the findings
34pub async fn generate_answer(
35    question: &str,
36    results: &[FileGroupedResult],
37    total_count: usize,
38    gathered_context: Option<&str>,
39    codebase_context: Option<&str>,
40    provider: &dyn LlmProvider,
41) -> Result<String> {
42    // Handle empty results - use gathered context if available, then codebase context
43    if results.is_empty() {
44        // Try gathered context first (from tools like search_documentation, gather_context)
45        if let Some(context) = gathered_context {
46            if !context.is_empty() {
47                // Generate answer from documentation/context alone
48                let prompt = build_context_only_prompt(question, context);
49                log::debug!("Generating answer from gathered context ({} chars)", prompt.len());
50                let answer = provider.complete(&prompt, false).await?;
51                let cleaned = strip_markdown_fences(&answer);
52                return Ok(cleaned.to_string());
53            }
54        }
55
56        // Try codebase context (language distribution, file counts, directories)
57        if let Some(context) = codebase_context {
58            if !context.is_empty() {
59                // Generate answer from codebase metadata alone
60                let prompt = build_codebase_context_prompt(question, context);
61                log::debug!("Generating answer from codebase context ({} chars)", prompt.len());
62                let answer = provider.complete(&prompt, false).await?;
63                let cleaned = strip_markdown_fences(&answer);
64                return Ok(cleaned.to_string());
65            }
66        }
67
68        return Ok(format!("No results found for: {}", question));
69    }
70
71    // Build the prompt with search results (and optional gathered context)
72    let prompt = build_answer_prompt(question, results, total_count, gathered_context);
73
74    log::debug!("Generating answer with prompt ({} chars)", prompt.len());
75
76    // Call LLM to generate answer (json_mode: false for plain text output)
77    let answer = provider.complete(&prompt, false).await?;
78
79    // Clean up the response (remove markdown fences if present)
80    let cleaned = strip_markdown_fences(&answer);
81
82    Ok(cleaned.to_string())
83}
84
85/// Build the prompt for answer generation (with optional gathered context)
86fn build_answer_prompt(
87    question: &str,
88    results: &[FileGroupedResult],
89    total_count: usize,
90    gathered_context: Option<&str>,
91) -> String {
92    let mut prompt = String::new();
93
94    // Instructions
95    prompt.push_str("You are analyzing code search results to answer a developer's question.\n\n");
96    prompt.push_str("IMPORTANT: Provide ONLY the answer text, without any markdown formatting, code fences, or explanatory prefixes.\n\n");
97
98    prompt.push_str(&format!("Question: {}\n\n", question));
99
100    // Add gathered context if available (documentation, codebase structure)
101    if let Some(context) = gathered_context {
102        if !context.is_empty() {
103            prompt.push_str("Additional Context (from documentation and codebase analysis):\n");
104            prompt.push_str("====================================================================\n\n");
105            prompt.push_str(context);
106            prompt.push_str("\n\n");
107        }
108    }
109
110    // Add search result summary
111    prompt.push_str(&format!("Found {} total matches across {} files.\n\n", total_count, results.len()));
112
113    prompt.push_str("Code Search Results:\n");
114    prompt.push_str("====================\n\n");
115
116    // Format results for the prompt (limit to avoid token overflow)
117    let mut match_count = 0;
118    for file_group in results {
119        if match_count >= MAX_MATCHES_IN_PROMPT {
120            prompt.push_str(&format!("\n... and {} more matches not shown\n", total_count - match_count));
121            break;
122        }
123
124        prompt.push_str(&format!("File: {}\n", file_group.path));
125
126        for match_result in &file_group.matches {
127            if match_count >= MAX_MATCHES_IN_PROMPT {
128                break;
129            }
130
131            log::debug!("Formatting match at {}:{} - context_before: {}, context_after: {}",
132                file_group.path, match_result.span.start_line,
133                match_result.context_before.len(), match_result.context_after.len());
134
135            // Show context before the match
136            for (idx, line) in match_result.context_before.iter().enumerate() {
137                let line_num = match_result.span.start_line.saturating_sub(match_result.context_before.len() - idx);
138                // Truncate long lines
139                let truncated = if line.len() > MAX_PREVIEW_LENGTH {
140                    format!("{}...", &line[..MAX_PREVIEW_LENGTH])
141                } else {
142                    line.clone()
143                };
144                prompt.push_str(&format!("  Line {}: {}\n", line_num, truncated.trim()));
145            }
146
147            // Show the match line itself
148            let preview = if match_result.preview.len() > MAX_PREVIEW_LENGTH {
149                format!("{}...", &match_result.preview[..MAX_PREVIEW_LENGTH])
150            } else {
151                match_result.preview.clone()
152            };
153
154            prompt.push_str(&format!(
155                "  Line {}-{}: {}\n",
156                match_result.span.start_line,
157                match_result.span.end_line,
158                preview.trim()
159            ));
160
161            // Show context after the match
162            for (idx, line) in match_result.context_after.iter().enumerate() {
163                let line_num = match_result.span.start_line + idx + 1;
164                // Truncate long lines
165                let truncated = if line.len() > MAX_PREVIEW_LENGTH {
166                    format!("{}...", &line[..MAX_PREVIEW_LENGTH])
167                } else {
168                    line.clone()
169                };
170                prompt.push_str(&format!("  Line {}: {}\n", line_num, truncated.trim()));
171            }
172
173            match_count += 1;
174        }
175
176        prompt.push_str("\n");
177    }
178
179    // Instructions for answer format
180    prompt.push_str("\nProvide a conversational answer that:\n");
181    prompt.push_str("1. Directly answers the question based on the search results\n");
182    prompt.push_str("2. References specific files and line numbers where relevant\n");
183    prompt.push_str("3. Summarizes patterns or common approaches if multiple results are similar\n");
184    prompt.push_str("4. Is concise but informative (typically 2-4 sentences)\n");
185    prompt.push_str("5. Only mentions information that appears in the search results above\n\n");
186
187    prompt.push_str("Answer (plain text only, no markdown):\n");
188
189    prompt
190}
191
192/// Build prompt for answering from context alone (no code search results)
193fn build_context_only_prompt(question: &str, gathered_context: &str) -> String {
194    let mut prompt = String::new();
195
196    prompt.push_str("You are answering a developer's question using documentation and codebase context.\n\n");
197    prompt.push_str("IMPORTANT: Provide ONLY the answer text, without any markdown formatting, code fences, or explanatory prefixes.\n\n");
198
199    prompt.push_str(&format!("Question: {}\n\n", question));
200
201    prompt.push_str("Available Context (from documentation and codebase analysis):\n");
202    prompt.push_str("================================================================\n\n");
203    prompt.push_str(gathered_context);
204    prompt.push_str("\n\n");
205
206    prompt.push_str("Provide a conversational answer that:\n");
207    prompt.push_str("1. Directly answers the question based on the context above\n");
208    prompt.push_str("2. References documentation sections or files where relevant\n");
209    prompt.push_str("3. Is concise but informative (typically 2-4 sentences)\n");
210    prompt.push_str("4. Only mentions information that appears in the context above\n\n");
211
212    prompt.push_str("Answer (plain text only, no markdown):\n");
213
214    prompt
215}
216
217/// Build prompt for answering from codebase metadata alone (file counts, languages, directories)
218fn build_codebase_context_prompt(question: &str, codebase_context: &str) -> String {
219    let mut prompt = String::new();
220
221    prompt.push_str("You are answering a developer's question using codebase metadata.\n\n");
222    prompt.push_str("IMPORTANT: Provide ONLY the answer text, without any markdown formatting, code fences, or explanatory prefixes.\n\n");
223
224    prompt.push_str(&format!("Question: {}\n\n", question));
225
226    prompt.push_str("Codebase Metadata:\n");
227    prompt.push_str("==================\n\n");
228    prompt.push_str(codebase_context);
229    prompt.push_str("\n\n");
230
231    prompt.push_str("Provide a conversational answer that:\n");
232    prompt.push_str("1. Directly answers the question using the metadata above\n");
233    prompt.push_str("2. Uses specific numbers and percentages from the metadata\n");
234    prompt.push_str("3. Is concise but informative (typically 1-2 sentences)\n");
235    prompt.push_str("4. Only mentions information that appears in the metadata above\n\n");
236
237    prompt.push_str("Answer (plain text only, no markdown):\n");
238
239    prompt
240}
241
242/// Strip markdown code fences from LLM response
243///
244/// Some LLMs add markdown formatting even when instructed not to.
245fn strip_markdown_fences(text: &str) -> &str {
246    let trimmed = text.trim();
247
248    // Check for markdown code fence pattern
249    if trimmed.starts_with("```") && trimmed.ends_with("```") {
250        // Remove opening fence (either ```markdown, ```text, or just ```)
251        let without_start = if let Some(rest) = trimmed.strip_prefix("```markdown") {
252            rest
253        } else if let Some(rest) = trimmed.strip_prefix("```text") {
254            rest
255        } else if let Some(rest) = trimmed.strip_prefix("```") {
256            rest
257        } else {
258            return trimmed;
259        };
260
261        // Remove closing fence
262        let without_end = without_start.strip_suffix("```")
263            .unwrap_or(without_start);
264
265        without_end.trim()
266    } else {
267        trimmed
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_strip_markdown_fences() {
277        let input = "```\nThis is the answer\n```";
278        assert_eq!(strip_markdown_fences(input), "This is the answer");
279    }
280
281    #[test]
282    fn test_strip_markdown_fences_with_language() {
283        let input = "```text\nThis is the answer\n```";
284        assert_eq!(strip_markdown_fences(input), "This is the answer");
285    }
286
287    #[test]
288    fn test_strip_markdown_fences_no_fences() {
289        let input = "This is the answer";
290        assert_eq!(strip_markdown_fences(input), "This is the answer");
291    }
292
293    #[test]
294    fn test_build_answer_prompt_empty_results() {
295        let results: Vec<FileGroupedResult> = vec![];
296        let prompt = build_answer_prompt("Find TODOs", &results, 0, None);
297
298        assert!(prompt.contains("Found 0 total matches"));
299        assert!(prompt.contains("Question: Find TODOs"));
300    }
301}