|
1 | | -use anyhow::Result; |
2 | | -use serde::{Deserialize, Serialize}; |
3 | | -use std::collections::{HashMap, HashSet}; |
4 | | -use std::path::Path; |
5 | | - |
6 | | -use crate::adapters; |
7 | | -use crate::config; |
8 | | -use crate::core; |
9 | | - |
10 | | -#[derive(Debug, Default, Serialize, Deserialize)] |
11 | | -pub struct FeedbackTypeStats { |
12 | | - #[serde(default)] |
13 | | - pub accepted: usize, |
14 | | - #[serde(default)] |
15 | | - pub rejected: usize, |
16 | | -} |
17 | | - |
18 | | -/// Tracks acceptance/rejection counts for a specific pattern (category, file extension, etc.) |
19 | | -#[derive(Debug, Default, Clone, Serialize, Deserialize)] |
20 | | -pub struct FeedbackPatternStats { |
21 | | - #[serde(default)] |
22 | | - pub accepted: usize, |
23 | | - #[serde(default)] |
24 | | - pub rejected: usize, |
25 | | -} |
26 | | - |
27 | | -impl FeedbackPatternStats { |
28 | | - pub fn acceptance_rate(&self) -> f32 { |
29 | | - let total = self.total(); |
30 | | - if total == 0 { |
31 | | - return 0.5; // neutral when no data |
32 | | - } |
33 | | - self.accepted as f32 / total as f32 |
34 | | - } |
35 | | - |
36 | | - pub fn total(&self) -> usize { |
37 | | - self.accepted + self.rejected |
38 | | - } |
39 | | -} |
40 | | - |
41 | | -#[derive(Debug, Default, Serialize, Deserialize)] |
42 | | -pub struct FeedbackStore { |
43 | | - #[serde(default)] |
44 | | - pub suppress: HashSet<String>, |
45 | | - #[serde(default)] |
46 | | - pub accept: HashSet<String>, |
47 | | - #[serde(default)] |
48 | | - pub by_comment_type: HashMap<String, FeedbackTypeStats>, |
49 | | - /// Feedback stats keyed by category (e.g., "Bug", "Security", "Performance"). |
50 | | - #[serde(default)] |
51 | | - pub by_category: HashMap<String, FeedbackPatternStats>, |
52 | | - /// Feedback stats keyed by file extension glob (e.g., "*.rs", "*.test.ts"). |
53 | | - #[serde(default)] |
54 | | - pub by_file_pattern: HashMap<String, FeedbackPatternStats>, |
55 | | - /// Feedback stats keyed by composite "category|*.ext" (e.g., "Bug|*.rs"). |
56 | | - #[serde(default)] |
57 | | - pub by_category_file_pattern: HashMap<String, FeedbackPatternStats>, |
58 | | -} |
59 | | - |
60 | | -pub fn derive_file_patterns(path: &Path) -> Vec<String> { |
61 | | - let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { |
62 | | - return Vec::new(); |
63 | | - }; |
64 | | - |
65 | | - let parts: Vec<&str> = file_name.split('.').collect(); |
66 | | - if parts.len() < 2 { |
67 | | - return Vec::new(); |
68 | | - } |
69 | | - |
70 | | - let mut patterns = Vec::new(); |
71 | | - for start in 1..parts.len() { |
72 | | - let pattern = format!("*.{}", parts[start..].join(".")); |
73 | | - if !patterns.contains(&pattern) { |
74 | | - patterns.push(pattern); |
75 | | - } |
76 | | - } |
77 | | - |
78 | | - patterns |
79 | | -} |
80 | | - |
81 | | -fn update_pattern_stats(stats: &mut FeedbackPatternStats, accepted: bool) { |
82 | | - if accepted { |
83 | | - stats.accepted += 1; |
84 | | - } else { |
85 | | - stats.rejected += 1; |
86 | | - } |
87 | | -} |
88 | | - |
89 | | -impl FeedbackStore { |
90 | | - /// Record a feedback event for enhanced pattern tracking. |
91 | | - #[cfg(test)] |
92 | | - pub fn record_feedback(&mut self, category: &str, file_pattern: Option<&str>, accepted: bool) { |
93 | | - let patterns = file_pattern |
94 | | - .into_iter() |
95 | | - .map(str::to_string) |
96 | | - .collect::<Vec<_>>(); |
97 | | - self.record_feedback_patterns(category, &patterns, accepted); |
98 | | - } |
99 | | - |
100 | | - /// Record a feedback event across one or more file-pattern buckets. |
101 | | - pub fn record_feedback_patterns<S>( |
102 | | - &mut self, |
103 | | - category: &str, |
104 | | - file_patterns: &[S], |
105 | | - accepted: bool, |
106 | | - ) where |
107 | | - S: AsRef<str>, |
108 | | - { |
109 | | - // Update by_category |
110 | | - let cat_stats = self.by_category.entry(category.to_string()).or_default(); |
111 | | - update_pattern_stats(cat_stats, accepted); |
112 | | - |
113 | | - // Update by_file_pattern |
114 | | - let mut unique_patterns = HashSet::new(); |
115 | | - for pattern in file_patterns { |
116 | | - let pattern = pattern.as_ref().trim(); |
117 | | - if pattern.is_empty() { |
118 | | - continue; |
119 | | - } |
120 | | - unique_patterns.insert(pattern.to_string()); |
121 | | - } |
122 | | - |
123 | | - for pattern in unique_patterns { |
124 | | - let fp_stats = self.by_file_pattern.entry(pattern.clone()).or_default(); |
125 | | - update_pattern_stats(fp_stats, accepted); |
126 | | - |
127 | | - // Update composite key |
128 | | - let composite = format!("{}|{}", category, pattern); |
129 | | - let comp_stats = self.by_category_file_pattern.entry(composite).or_default(); |
130 | | - update_pattern_stats(comp_stats, accepted); |
131 | | - } |
132 | | - } |
133 | | -} |
134 | | - |
135 | | -pub fn load_feedback_store_from_path(path: &Path) -> FeedbackStore { |
136 | | - match std::fs::read_to_string(path) { |
137 | | - Ok(content) => serde_json::from_str(&content).unwrap_or_default(), |
138 | | - Err(_) => FeedbackStore::default(), |
139 | | - } |
140 | | -} |
141 | | - |
142 | | -pub fn load_feedback_store(config: &config::Config) -> FeedbackStore { |
143 | | - load_feedback_store_from_path(&config.feedback_path) |
144 | | -} |
145 | | - |
146 | | -/// Generate feedback context to inject into the review prompt. |
147 | | -/// |
148 | | -/// Scans the feedback store for statistically significant patterns |
149 | | -/// and generates guidance text for the LLM reviewer. |
150 | | -pub fn generate_feedback_context(store: &FeedbackStore) -> String { |
151 | | - let min_observations = 5; |
152 | | - let mut patterns: Vec<String> = Vec::new(); |
153 | | - |
154 | | - // Scan by_category for significant patterns |
155 | | - for (category, stats) in &store.by_category { |
156 | | - if stats.total() < min_observations { |
157 | | - continue; |
158 | | - } |
159 | | - let rate = stats.acceptance_rate(); |
160 | | - if rate >= 0.7 { |
161 | | - patterns.push(format!( |
162 | | - "- {} findings are usually accepted ({:.0}% acceptance rate) — be thorough on {} issues", |
163 | | - category, rate * 100.0, category.to_lowercase() |
164 | | - )); |
165 | | - } else if rate < 0.3 { |
166 | | - patterns.push(format!( |
167 | | - "- {} findings are frequently rejected ({:.0}% acceptance rate) — only flag clear {} issues", |
168 | | - category, rate * 100.0, category.to_lowercase() |
169 | | - )); |
170 | | - } |
171 | | - } |
172 | | - |
173 | | - // Scan by_file_pattern for low-acceptance patterns |
174 | | - for (pattern, stats) in &store.by_file_pattern { |
175 | | - if stats.total() < min_observations { |
176 | | - continue; |
177 | | - } |
178 | | - let rate = stats.acceptance_rate(); |
179 | | - if rate < 0.3 { |
180 | | - patterns.push(format!( |
181 | | - "- Comments on {} files are usually rejected ({:.0}% acceptance rate) — be more conservative", |
182 | | - pattern, rate * 100.0 |
183 | | - )); |
184 | | - } |
185 | | - } |
186 | | - |
187 | | - // Cap at top 10 patterns to avoid prompt bloat |
188 | | - patterns.truncate(10); |
189 | | - |
190 | | - if patterns.is_empty() { |
191 | | - return String::new(); |
192 | | - } |
193 | | - |
194 | | - let mut context = String::from( |
195 | | - "## Learned Feedback Patterns\nBased on historical feedback from this project:\n", |
196 | | - ); |
197 | | - for pattern in &patterns { |
198 | | - context.push_str(pattern); |
199 | | - context.push('\n'); |
200 | | - } |
201 | | - context |
202 | | -} |
203 | | - |
204 | | -pub fn save_feedback_store(path: &Path, store: &FeedbackStore) -> Result<()> { |
205 | | - atomic_write_string(path, &serde_json::to_string_pretty(store)?) |
206 | | -} |
207 | | - |
208 | | -#[allow(dead_code)] |
209 | | -pub async fn record_semantic_feedback_example( |
210 | | - config: &config::Config, |
211 | | - comment: &core::Comment, |
212 | | - accepted: bool, |
213 | | -) -> Result<()> { |
214 | | - record_semantic_feedback_examples(config, std::slice::from_ref(comment), accepted).await?; |
215 | | - Ok(()) |
216 | | -} |
217 | | - |
218 | | -pub async fn record_semantic_feedback_examples( |
219 | | - config: &config::Config, |
220 | | - comments: &[core::Comment], |
221 | | - accepted: bool, |
222 | | -) -> Result<usize> { |
223 | | - if comments.is_empty() { |
224 | | - return Ok(0); |
225 | | - } |
226 | | - |
227 | | - let semantic_path = core::default_semantic_feedback_path(&config.feedback_path); |
228 | | - let mut store = core::load_semantic_feedback_store(&semantic_path); |
229 | | - let model_config = config.to_model_config_for_role(config::ModelRole::Embedding); |
230 | | - let adapter = adapters::llm::create_adapter(&model_config).ok(); |
231 | | - core::align_semantic_feedback_store(&mut store, adapter.as_deref()); |
232 | | - |
233 | | - let embedding_texts = comments |
234 | | - .iter() |
235 | | - .map(|comment| { |
236 | | - core::build_feedback_embedding_text(&comment.content, comment.category.as_str()) |
237 | | - }) |
238 | | - .collect::<Vec<_>>(); |
239 | | - let embeddings = core::embed_texts_with_fallback(adapter.as_deref(), &embedding_texts).await; |
240 | | - let before = store.examples.len(); |
241 | | - let timestamp = chrono::Utc::now().to_rfc3339(); |
242 | | - |
243 | | - for (comment, embedding) in comments.iter().zip(embeddings.into_iter()) { |
244 | | - store.add_example(core::SemanticFeedbackExample { |
245 | | - content: comment.content.clone(), |
246 | | - category: comment.category.as_str().to_string(), |
247 | | - file_patterns: derive_file_patterns(&comment.file_path), |
248 | | - accepted, |
249 | | - created_at: timestamp.clone(), |
250 | | - embedding, |
251 | | - }); |
252 | | - } |
253 | | - |
254 | | - core::save_semantic_feedback_store(&semantic_path, &store)?; |
255 | | - Ok(store.examples.len().saturating_sub(before)) |
256 | | -} |
257 | | - |
258 | | -fn atomic_write_string(path: &Path, content: &str) -> Result<()> { |
259 | | - if let Some(parent) = path.parent() { |
260 | | - std::fs::create_dir_all(parent)?; |
261 | | - } |
262 | | - |
263 | | - let file_name = path |
264 | | - .file_name() |
265 | | - .and_then(|value| value.to_str()) |
266 | | - .unwrap_or("feedback.json"); |
267 | | - let tmp_path = path.with_file_name(format!("{}.{}.tmp", file_name, std::process::id())); |
268 | | - std::fs::write(&tmp_path, content)?; |
269 | | - std::fs::rename(&tmp_path, path)?; |
270 | | - Ok(()) |
271 | | -} |
| 1 | +#[path = "feedback/context.rs"] |
| 2 | +mod context; |
| 3 | +#[path = "feedback/patterns.rs"] |
| 4 | +mod patterns; |
| 5 | +#[path = "feedback/persistence.rs"] |
| 6 | +mod persistence; |
| 7 | +#[path = "feedback/semantic.rs"] |
| 8 | +mod semantic; |
| 9 | +#[path = "feedback/store.rs"] |
| 10 | +mod store; |
| 11 | + |
| 12 | +#[allow(unused_imports)] |
| 13 | +pub use context::generate_feedback_context; |
| 14 | +#[allow(unused_imports)] |
| 15 | +pub use patterns::derive_file_patterns; |
| 16 | +#[allow(unused_imports)] |
| 17 | +pub use persistence::{load_feedback_store, load_feedback_store_from_path, save_feedback_store}; |
| 18 | +#[allow(unused_imports)] |
| 19 | +pub use semantic::{record_semantic_feedback_example, record_semantic_feedback_examples}; |
| 20 | +#[allow(unused_imports)] |
| 21 | +pub use store::{FeedbackPatternStats, FeedbackStore, FeedbackTypeStats}; |
272 | 22 |
|
273 | 23 | #[cfg(test)] |
274 | 24 | mod tests { |
275 | 25 | use super::*; |
| 26 | + use std::path::Path; |
276 | 27 |
|
277 | 28 | #[test] |
278 | 29 | fn feedback_store_default_is_empty() { |
|
0 commit comments