reflex/semantic/
answer.rs1use anyhow::Result;
7use crate::models::FileGroupedResult;
8use super::providers::LlmProvider;
9
10const MAX_MATCHES_IN_PROMPT: usize = 50;
12
13const MAX_PREVIEW_LENGTH: usize = 200;
15
16pub 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 if results.is_empty() {
44 if let Some(context) = gathered_context {
46 if !context.is_empty() {
47 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 if let Some(context) = codebase_context {
58 if !context.is_empty() {
59 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 let prompt = build_answer_prompt(question, results, total_count, gathered_context);
73
74 log::debug!("Generating answer with prompt ({} chars)", prompt.len());
75
76 let answer = provider.complete(&prompt, false).await?;
78
79 let cleaned = strip_markdown_fences(&answer);
81
82 Ok(cleaned.to_string())
83}
84
85fn 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 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 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 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 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 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 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 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 for (idx, line) in match_result.context_after.iter().enumerate() {
163 let line_num = match_result.span.start_line + idx + 1;
164 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 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
192fn 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
217fn 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
242fn strip_markdown_fences(text: &str) -> &str {
246 let trimmed = text.trim();
247
248 if trimmed.starts_with("```") && trimmed.ends_with("```") {
250 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 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}