Skip to content

Commit c8af0ed

Browse files
haasonsaasclaude
andauthored
feat: quality improvements — multi-model routing, incremental reviews, structured logging (#35)
* feat: 9 quality improvements — multi-model routing, incremental reviews, structured logging, tests - Fix panic-prone unwrap() calls in production paths (adapters, verification) - Add 82 new tests: API layer (36), database layer (27), state (2), config (4), incremental SHA (2), and more - GitHub suggestion blocks: multi-line support with start_line/line for one-click PR fixes - Incremental review: push-by-push delta using last_reviewed_shas and compare API - Multi-model routing: ModelRole::Fast cascades model_fast → model_weak → primary - Wire up dead code: InteractiveCommand → webhook, storage → API, model_name → pipeline, detect_context_window → doctor - Remove unused Plugin trait, legacy Ollama types - Replace all eprintln!/println! with tracing::error!/warn!/info! in server modules - Add delete_review and prune_reviews API endpoints - Reduce cloning by inlining OllamaShowRequest/Response Closes #8, #16, #26 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review findings — pagination, prune safety, redundant adapter - Fix list_reviews pagination: fetch enough rows from storage to cover the requested page, not just 2x per_page - Fix prune_reviews: also prune in-memory state, clamp max_age_days and max_count to >= 1 to prevent negative values deleting all reviews - Avoid redundant adapter allocation in smart_review when fast model matches primary model — reuse existing adapter reference - Run cargo fmt to fix CI formatting failure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 45e0b97 commit c8af0ed

File tree

19 files changed

+1343
-213
lines changed

19 files changed

+1343
-213
lines changed

src/adapters/llm.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ pub struct Usage {
6565
#[async_trait]
6666
pub trait LLMAdapter: Send + Sync {
6767
async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
68-
#[allow(dead_code)]
6968
fn model_name(&self) -> &str;
7069
}
7170

@@ -99,9 +98,8 @@ pub fn create_adapter(config: &ModelConfig) -> Result<Box<dyn LLMAdapter>> {
9998
}
10099

101100
// Vendor-prefixed model IDs (vendor/model)
102-
if config.model_name.contains('/') {
103-
let (vendor, model) = config.model_name.split_once('/').unwrap();
104-
let model = model.to_string();
101+
if let Some((vendor, model_suffix)) = config.model_name.split_once('/') {
102+
let model = model_suffix.to_string();
105103
match vendor {
106104
"anthropic" => {
107105
// Route anthropic/ prefix directly to the Anthropic adapter

src/adapters/ollama.rs

Lines changed: 8 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -43,57 +43,7 @@ struct OllamaChatResponse {
4343
eval_count: Option<usize>,
4444
}
4545

46-
// -- Legacy generate API types (kept for reference / future use) --
47-
48-
#[allow(dead_code)]
49-
#[derive(Serialize)]
50-
struct OllamaGenerateRequest {
51-
model: String,
52-
prompt: String,
53-
system: String,
54-
temperature: f32,
55-
num_predict: usize,
56-
stream: bool,
57-
}
58-
59-
#[allow(dead_code)]
60-
#[derive(Deserialize)]
61-
struct OllamaGenerateResponse {
62-
response: String,
63-
model: String,
64-
done: bool,
65-
_context: Option<Vec<i32>>,
66-
_total_duration: Option<u64>,
67-
prompt_eval_count: Option<usize>,
68-
eval_count: Option<usize>,
69-
}
70-
7146
// -- /api/show types for context window detection --
72-
73-
#[allow(dead_code)]
74-
#[derive(Serialize)]
75-
struct OllamaShowRequest {
76-
name: String,
77-
}
78-
79-
#[allow(dead_code)]
80-
#[derive(Deserialize)]
81-
struct OllamaShowResponse {
82-
#[serde(default)]
83-
parameters: Option<String>,
84-
#[allow(dead_code)]
85-
#[serde(default)]
86-
details: Option<OllamaShowDetails>,
87-
}
88-
89-
#[allow(dead_code)]
90-
#[derive(Deserialize)]
91-
struct OllamaShowDetails {
92-
family: Option<String>,
93-
parameter_size: Option<String>,
94-
quantization_level: Option<String>,
95-
}
96-
9747
impl OllamaAdapter {
9848
pub fn new(config: ModelConfig) -> Result<Self> {
9949
let base_url = config
@@ -131,27 +81,20 @@ impl OllamaAdapter {
13181
///
13282
/// Returns `Some(num_ctx)` if the model metadata contains a `num_ctx` parameter,
13383
/// or `None` if the endpoint is unreachable or the parameter is not found.
134-
#[allow(dead_code)]
84+
#[allow(dead_code)] // Public API, used in tests; production uses OfflineModelManager
13585
pub async fn detect_context_window(&self) -> Option<usize> {
13686
let url = format!("{}/api/show", self.base_url);
137-
let show_request = OllamaShowRequest {
138-
name: self.model_name_bare().to_string(),
139-
};
87+
let body = serde_json::json!({"name": self.model_name_bare()});
14088

141-
let response = self
142-
.client
143-
.post(&url)
144-
.json(&show_request)
145-
.send()
146-
.await
147-
.ok()?;
89+
let response = self.client.post(&url).json(&body).send().await.ok()?;
14890

14991
if !response.status().is_success() {
15092
return None;
15193
}
15294

153-
let show_response: OllamaShowResponse = response.json().await.ok()?;
154-
parse_num_ctx(show_response.parameters.as_deref())
95+
let value: serde_json::Value = response.json().await.ok()?;
96+
let parameters = value.get("parameters").and_then(|p| p.as_str());
97+
parse_num_ctx(parameters)
15598
}
15699
}
157100

@@ -162,8 +105,8 @@ impl OllamaAdapter {
162105
/// num_ctx 4096
163106
/// temperature 0.8
164107
/// ```
165-
#[allow(dead_code)]
166-
fn parse_num_ctx(parameters: Option<&str>) -> Option<usize> {
108+
#[allow(dead_code)] // Called by detect_context_window
109+
pub(crate) fn parse_num_ctx(parameters: Option<&str>) -> Option<usize> {
167110
let params = parameters?;
168111
for line in params.lines() {
169112
let line = line.trim();

src/commands/doctor.rs

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,7 @@ pub async fn doctor_command(config: Config) -> Result<()> {
131131

132132
// Context window detection (Ollama only)
133133
if endpoint_type == "ollama" {
134-
if let Some(ctx_size) =
135-
detect_model_context_window(&client, &base_url, &recommended.name).await
136-
{
134+
if let Ok(Some(ctx_size)) = manager.detect_context_window(&recommended.name).await {
137135
println!(
138136
" Context window: {} tokens (detected from model)",
139137
ctx_size
@@ -289,50 +287,6 @@ fn estimate_tokens(text: &str) -> usize {
289287
(text.len() / 4).max(1)
290288
}
291289

292-
/// Query Ollama's `/api/show` to detect the model's context window size.
293-
async fn detect_model_context_window(
294-
client: &Client,
295-
base_url: &str,
296-
model_name: &str,
297-
) -> Option<usize> {
298-
let url = format!("{}/api/show", base_url);
299-
let body = serde_json::json!({"name": model_name});
300-
let resp = client.post(&url).json(&body).send().await.ok()?;
301-
if !resp.status().is_success() {
302-
return None;
303-
}
304-
let text = resp.text().await.ok()?;
305-
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
306-
307-
// The "parameters" field is a newline-delimited string of key-value pairs
308-
if let Some(params) = value.get("parameters").and_then(|p| p.as_str()) {
309-
for line in params.lines() {
310-
let trimmed = line.trim();
311-
if trimmed.starts_with("num_ctx") {
312-
if let Some(val) = trimmed.split_whitespace().nth(1) {
313-
return val.parse().ok();
314-
}
315-
}
316-
}
317-
}
318-
319-
// Also check model_info for context_length
320-
if let Some(info) = value.get("model_info") {
321-
// Try common key patterns
322-
for key in &[
323-
"context_length",
324-
"llama.context_length",
325-
"general.context_length",
326-
] {
327-
if let Some(ctx) = info.get(*key).and_then(|v| v.as_u64()) {
328-
return Some(ctx as usize);
329-
}
330-
}
331-
}
332-
333-
None
334-
}
335-
336290
/// Print system resource information (RAM and GPU if available).
337291
fn check_system_resources() {
338292
println!("System Resources:");

src/commands/git.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ async fn suggest_commit_message(config: config::Config) -> Result<()> {
7272
return Ok(());
7373
}
7474

75-
let model_config = config.to_model_config();
75+
// Use Fast model for commit message suggestion (lightweight task)
76+
let model_config = config.to_model_config_for_role(config::ModelRole::Fast);
7677

7778
let adapter = adapters::llm::create_adapter(&model_config)?;
7879

@@ -114,7 +115,8 @@ async fn suggest_pr_title(config: config::Config) -> Result<()> {
114115
return Ok(());
115116
}
116117

117-
let model_config = config.to_model_config();
118+
// Use Fast model for PR title suggestion (lightweight task)
119+
let model_config = config.to_model_config_for_role(config::ModelRole::Fast);
118120

119121
let adapter = adapters::llm::create_adapter(&model_config)?;
120122

src/commands/pr.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ pub async fn pr_command(
8181
let diffs = core::DiffParser::parse_unified_diff(&diff_content)?;
8282
let git = core::GitIntegration::new(".")?;
8383

84-
let model_config = config.to_model_config();
85-
86-
let adapter = adapters::llm::create_adapter(&model_config)?;
84+
// Use Fast model for PR summary generation (lightweight task)
85+
let fast_config = config.to_model_config_for_role(config::ModelRole::Fast);
86+
let adapter = adapters::llm::create_adapter(&fast_config)?;
8787
let options = core::SummaryOptions {
8888
include_diagram: config.smart_review_diagram,
8989
};

src/commands/smart_review.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ pub async fn smart_review_command(
6262
let model_config = config.to_model_config();
6363

6464
let adapter = adapters::llm::create_adapter(&model_config)?;
65+
66+
// Use Fast model for PR summary and diagram generation (lightweight tasks).
67+
// Only create a separate adapter if model_fast differs from the primary model.
68+
let fast_config = config.to_model_config_for_role(config::ModelRole::Fast);
69+
let separate_fast_adapter: Option<Box<dyn adapters::llm::LLMAdapter>> =
70+
if fast_config.model_name != model_config.model_name {
71+
info!(
72+
"Using fast model '{}' for PR summary/diagram",
73+
fast_config.model_name
74+
);
75+
Some(adapters::llm::create_adapter(&fast_config)?)
76+
} else {
77+
None
78+
};
79+
let summary_adapter: &dyn adapters::llm::LLMAdapter =
80+
separate_fast_adapter.as_deref().unwrap_or(adapter.as_ref());
81+
6582
let mut all_comments = Vec::new();
6683
let mut pr_summary = if config.smart_review_summary {
6784
match core::GitIntegration::new(&repo_root) {
@@ -72,7 +89,7 @@ pub async fn smart_review_command(
7289
match core::PRSummaryGenerator::generate_summary_with_options(
7390
&diffs,
7491
&git,
75-
adapter.as_ref(),
92+
summary_adapter,
7693
options,
7794
)
7895
.await
@@ -94,7 +111,7 @@ pub async fn smart_review_command(
94111
};
95112

96113
if config.smart_review_diagram {
97-
match core::PRSummaryGenerator::generate_change_diagram(&diffs, adapter.as_ref()).await {
114+
match core::PRSummaryGenerator::generate_change_diagram(&diffs, summary_adapter).await {
98115
Ok(Some(diagram)) => {
99116
if let Some(summary) = &mut pr_summary {
100117
summary.visual_diff = Some(diagram);

src/config.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub enum ModelRole {
2020
Reasoning,
2121
/// Embedding model for RAG indexing.
2222
Embedding,
23+
/// Fast model for lightweight LLM tasks: PR summaries, commit messages,
24+
/// PR titles, diagram generation. Falls back to Weak, then Primary.
25+
Fast,
2326
}
2427

2528
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -51,6 +54,11 @@ pub struct Config {
5154
#[serde(default)]
5255
pub model_weak: Option<String>,
5356

57+
/// Fast model for lightweight LLM tasks: PR summaries, commit messages,
58+
/// PR titles, diagram generation. Falls back to model_weak, then primary.
59+
#[serde(default)]
60+
pub model_fast: Option<String>,
61+
5462
/// Reasoning-capable model for complex analysis and self-reflection.
5563
#[serde(default)]
5664
pub model_reasoning: Option<String>,
@@ -338,6 +346,7 @@ impl Default for Config {
338346
Self {
339347
model: default_model(),
340348
model_weak: None,
349+
model_fast: None,
341350
model_reasoning: None,
342351
model_embedding: None,
343352
fallback_models: Vec::new(),
@@ -914,6 +923,11 @@ impl Config {
914923
ModelRole::Weak => self.model_weak.as_deref().unwrap_or(&self.model),
915924
ModelRole::Reasoning => self.model_reasoning.as_deref().unwrap_or(&self.model),
916925
ModelRole::Embedding => self.model_embedding.as_deref().unwrap_or(&self.model),
926+
ModelRole::Fast => self
927+
.model_fast
928+
.as_deref()
929+
.or(self.model_weak.as_deref())
930+
.unwrap_or(&self.model),
917931
}
918932
}
919933

@@ -1650,6 +1664,50 @@ mod tests {
16501664
);
16511665
}
16521666

1667+
#[test]
1668+
fn test_model_role_fast_fallback_to_primary() {
1669+
let config = Config {
1670+
model: "claude-sonnet-4-6".to_string(),
1671+
model_fast: None,
1672+
model_weak: None,
1673+
..Config::default()
1674+
};
1675+
assert_eq!(config.model_for_role(ModelRole::Fast), "claude-sonnet-4-6");
1676+
}
1677+
1678+
#[test]
1679+
fn test_model_role_fast_fallback_to_weak() {
1680+
let config = Config {
1681+
model: "claude-sonnet-4-6".to_string(),
1682+
model_fast: None,
1683+
model_weak: Some("claude-haiku-4-5".to_string()),
1684+
..Config::default()
1685+
};
1686+
assert_eq!(config.model_for_role(ModelRole::Fast), "claude-haiku-4-5");
1687+
}
1688+
1689+
#[test]
1690+
fn test_model_role_fast_explicit() {
1691+
let config = Config {
1692+
model: "claude-sonnet-4-6".to_string(),
1693+
model_fast: Some("gpt-4o-mini".to_string()),
1694+
model_weak: Some("claude-haiku-4-5".to_string()),
1695+
..Config::default()
1696+
};
1697+
assert_eq!(config.model_for_role(ModelRole::Fast), "gpt-4o-mini");
1698+
}
1699+
1700+
#[test]
1701+
fn test_to_model_config_for_role_fast() {
1702+
let config = Config {
1703+
model: "claude-sonnet-4-6".to_string(),
1704+
model_fast: Some("gpt-4o-mini".to_string()),
1705+
..Config::default()
1706+
};
1707+
let fast_config = config.to_model_config_for_role(ModelRole::Fast);
1708+
assert_eq!(fast_config.model_name, "gpt-4o-mini");
1709+
}
1710+
16531711
#[test]
16541712
fn test_to_model_config_for_role_uses_correct_model() {
16551713
let config = Config {

src/core/interactive.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@ use anyhow::Result;
33
use regex::Regex;
44
use std::collections::HashSet;
55

6-
#[allow(dead_code)]
76
pub struct InteractiveCommand {
87
pub command: CommandType,
98
pub args: Vec<String>,
9+
#[allow(dead_code)] // Set by webhook handler when PR context is available
1010
pub context: Option<String>,
1111
}
1212

13-
#[allow(dead_code)]
1413
#[derive(Debug, Clone, PartialEq)]
1514
pub enum CommandType {
1615
Review,
@@ -21,7 +20,6 @@ pub enum CommandType {
2120
Config,
2221
}
2322

24-
#[allow(dead_code)]
2523
impl InteractiveCommand {
2624
pub fn parse(comment: &str) -> Option<Self> {
2725
let command_regex = Regex::new(r"@diffscope\s+(\w+)(?:\s+(.*))?").ok()?;
@@ -186,6 +184,10 @@ impl InteractiveCommand {
186184
))
187185
}
188186

187+
pub fn help_text() -> String {
188+
Self::get_help_text()
189+
}
190+
189191
fn get_help_text() -> String {
190192
r#"## 🤖 DiffScope Interactive Commands
191193
@@ -244,6 +246,8 @@ Interactive commands respect these configurations."#
244246
}
245247
}
246248

249+
/// Manages per-session ignore patterns from @diffscope ignore commands.
250+
/// Will be wired into the review pipeline's triage filter.
247251
#[allow(dead_code)]
248252
pub struct InteractiveProcessor {
249253
ignored_patterns: HashSet<String>,

0 commit comments

Comments
 (0)