reflex/semantic/
answer.rs1use super::providers::LlmProvider;
7use crate::models::FileGroupedResult;
8use anyhow::Result;
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 && !context.is_empty()
47 {
48 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 if let Some(context) = codebase_context
61 && !context.is_empty()
62 {
63 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 let prompt = build_answer_prompt(question, results, total_count, gathered_context);
79
80 log::debug!("Generating answer with prompt ({} chars)", prompt.len());
81
82 let answer = provider.complete(&prompt, false).await?;
84
85 let cleaned = strip_markdown_fences(&answer);
87
88 Ok(cleaned.to_string())
89}
90
91fn 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 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 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 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 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 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 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 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 for (idx, line) in match_result.context_after.iter().enumerate() {
183 let line_num = match_result.span.start_line + idx + 1;
184 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 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
213fn 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
240fn 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
265fn strip_markdown_fences(text: &str) -> &str {
269 let trimmed = text.trim();
270
271 if trimmed.starts_with("```") && trimmed.ends_with("```") {
273 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 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}