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