Skip to content

Commit 95e764c

Browse files
committed
refactor: split review feedback helpers
Made-with: Cursor
1 parent 341a052 commit 95e764c

File tree

7 files changed

+301
-279
lines changed

7 files changed

+301
-279
lines changed

TODO.md

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,14 @@
1010

1111
## Immediate Queue
1212

13-
- [ ] `src/review/feedback.rs`
14-
- Split feedback stats/data models from file-pattern derivation.
15-
- Split JSON persistence and atomic-write helpers from store mutation helpers.
16-
- Split prompt-facing learned-feedback context generation from semantic example recording.
17-
- Split semantic embedding alignment/loading/saving from example ingestion.
18-
19-
## Review Backlog
20-
2113
- [ ] `src/review/triage.rs`
2214
- Split file-kind heuristics (`lock`, `generated`) from change-shape heuristics.
2315
- Split whitespace-only detection from comment-only detection.
2416
- Isolate `TriageResult` reporting helpers from diff classification.
2517
- Pull shared diff-line collection helpers out of `triage_diff()`.
18+
19+
## Review Backlog
20+
2621
- [ ] `src/review/compression.rs`
2722
- Split token estimation and skipped-summary helpers from compression stages.
2823
- Split deletion-only compression and clipping primitives from stage selection.

src/review/feedback.rs

Lines changed: 22 additions & 271 deletions
Original file line numberDiff line numberDiff line change
@@ -1,278 +1,29 @@
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};
27222

27323
#[cfg(test)]
27424
mod tests {
27525
use super::*;
26+
use std::path::Path;
27627

27728
#[test]
27829
fn feedback_store_default_is_empty() {

src/review/feedback/context.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use super::store::FeedbackStore;
2+
3+
/// Generate feedback context to inject into the review prompt.
4+
///
5+
/// Scans the feedback store for statistically significant patterns
6+
/// and generates guidance text for the LLM reviewer.
7+
pub fn generate_feedback_context(store: &FeedbackStore) -> String {
8+
let min_observations = 5;
9+
let mut patterns: Vec<String> = Vec::new();
10+
11+
for (category, stats) in &store.by_category {
12+
if stats.total() < min_observations {
13+
continue;
14+
}
15+
let rate = stats.acceptance_rate();
16+
if rate >= 0.7 {
17+
patterns.push(format!(
18+
"- {} findings are usually accepted ({:.0}% acceptance rate) — be thorough on {} issues",
19+
category, rate * 100.0, category.to_lowercase()
20+
));
21+
} else if rate < 0.3 {
22+
patterns.push(format!(
23+
"- {} findings are frequently rejected ({:.0}% acceptance rate) — only flag clear {} issues",
24+
category, rate * 100.0, category.to_lowercase()
25+
));
26+
}
27+
}
28+
29+
for (pattern, stats) in &store.by_file_pattern {
30+
if stats.total() < min_observations {
31+
continue;
32+
}
33+
let rate = stats.acceptance_rate();
34+
if rate < 0.3 {
35+
patterns.push(format!(
36+
"- Comments on {} files are usually rejected ({:.0}% acceptance rate) — be more conservative",
37+
pattern, rate * 100.0
38+
));
39+
}
40+
}
41+
42+
patterns.truncate(10);
43+
44+
if patterns.is_empty() {
45+
return String::new();
46+
}
47+
48+
let mut context = String::from(
49+
"## Learned Feedback Patterns\nBased on historical feedback from this project:\n",
50+
);
51+
for pattern in &patterns {
52+
context.push_str(pattern);
53+
context.push('\n');
54+
}
55+
context
56+
}

0 commit comments

Comments
 (0)