use super::super::ReviewLlm;
use crate::context::types::ContextSourceItemRecord;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct JudgeVerdict {
pub applies: bool,
pub relevance: f32,
}
pub(super) const JUDGE_SYSTEM_PROMPT: &str = r#"You are a code-review rule-relevance judge. You are given a diff and a numbered list of candidate review rules. For EACH rule, decide whether that rule's lesson actually applies to THIS diff — i.e. the diff touches the kind of code, pattern, or concern the rule is about.
Return ONLY a JSON array. Each element must be an object:
{"id": <index>, "applies": true|false, "relevance": <float 0..1>, "reason": "<short>"}
Judge relevance only — do NOT report code issues, do NOT invent rules, do NOT rewrite the rule. A rule "applies" when the diff plausibly involves the rule's subject, even if the diff does not necessarily violate it. When you are unsure, set "applies": true (keep the rule). Mark "applies": false only when the rule is clearly about unrelated code or concerns.
Return the raw JSON array only, no markdown, no explanation."#;
struct JudgeCandidate<'a> {
title: &'a str,
content: &'a str,
}
fn candidate_title(item: &ContextSourceItemRecord) -> &str {
item.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.unwrap_or(item.source_id.as_str())
}
fn build_judge_user_prompt(diff: &str, candidates: &[JudgeCandidate<'_>]) -> String {
const DIFF_LIMIT: usize = 8_000;
const RULE_BODY_LIMIT: usize = 600;
let trimmed = if diff.len() > DIFF_LIMIT {
&diff[..DIFF_LIMIT]
} else {
diff
};
let mut s = String::new();
s.push_str("## Diff\n```diff\n");
s.push_str(trimmed);
s.push_str("\n```\n\n## Candidate rules\n");
for (i, c) in candidates.iter().enumerate() {
let body: String = c.content.trim().chars().take(RULE_BODY_LIMIT).collect();
s.push_str(&format!(
"- id: {i}\n title: {}\n rule: {body}\n",
c.title
));
}
s
}
pub(super) fn parse_judge_response(text: &str) -> Option<HashMap<usize, JudgeVerdict>> {
let arr: Vec<serde_json::Value> =
if let Ok(v) = serde_json::from_str::<Vec<serde_json::Value>>(text.trim()) {
v
} else if let Some(start) = text.find("```") {
let after = &text[start + 3..];
let content_start = after.find('\n').map_or(0, |i| i + 1);
let end = after[content_start..].find("```")?;
let block = &after[content_start..content_start + end];
serde_json::from_str::<Vec<serde_json::Value>>(block.trim()).ok()?
} else if let (Some(start), Some(end)) = (text.find('['), text.rfind(']')) {
if end <= start {
return None;
}
serde_json::from_str::<Vec<serde_json::Value>>(&text[start..=end]).ok()?
} else {
return None;
};
let mut out = HashMap::new();
for item in arr {
let Some(obj) = item.as_object() else {
continue;
};
let Some(id) = obj
.get("id")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize)
else {
continue;
};
let applies = obj
.get("applies")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
let relevance = obj
.get("relevance")
.and_then(serde_json::Value::as_f64)
.unwrap_or(if applies { 1.0 } else { 0.0 }) as f32;
out.insert(
id,
JudgeVerdict {
applies,
relevance: relevance.clamp(0.0, 1.0),
},
);
}
Some(out)
}
pub(super) fn judge_filter_rules(
rules: Vec<ContextSourceItemRecord>,
verdicts: &HashMap<usize, JudgeVerdict>,
) -> Vec<ContextSourceItemRecord> {
if rules.is_empty() {
return rules;
}
let kept: Vec<ContextSourceItemRecord> = rules
.iter()
.enumerate()
.filter(|(idx, _)| verdicts.get(idx).is_none_or(|v| v.applies))
.map(|(_, item)| item.clone())
.collect();
if kept.is_empty() {
return rules;
}
kept
}
pub(super) async fn run_applicability_judge(
llm: &dyn ReviewLlm,
enabled: bool,
diff: &str,
rules: Vec<ContextSourceItemRecord>,
) -> Vec<ContextSourceItemRecord> {
if !enabled {
return rules;
}
if rules.len() < 2 {
return rules;
}
if diff.is_empty() {
return rules;
}
let candidates: Vec<JudgeCandidate<'_>> = rules
.iter()
.map(|item| JudgeCandidate {
title: candidate_title(item),
content: item.content.as_str(),
})
.collect();
let user_prompt = build_judge_user_prompt(diff, &candidates);
let response = match llm.chat(JUDGE_SYSTEM_PROMPT, &user_prompt).await {
Ok(r) => r,
Err(e) => {
eprintln!("[applicability_judge] judge call failed: {e:?}; keeping all rules");
return rules;
}
};
let verdicts = match parse_judge_response(&response) {
Some(map) if !map.is_empty() => map,
_ => {
eprintln!(
"[applicability_judge] could not parse judge response; keeping all rules unchanged"
);
return rules;
}
};
let before = rules.len();
let filtered = judge_filter_rules(rules, &verdicts);
if crate::env::fix_debug() {
eprintln!(
"[applicability_judge] pool {before} -> {} after judge filter",
filtered.len(),
);
}
filtered
}
#[cfg(test)]
mod tests {
use super::*;
use crate::review::ReviewLlm;
use std::sync::Mutex;
fn rule(source_id: &str, title: &str, content: &str, score: f64) -> ContextSourceItemRecord {
ContextSourceItemRecord {
source_type: "rule".into(),
source_id: source_id.into(),
relative_path: None,
start_line: None,
end_line: None,
title: Some(title.into()),
content: content.into(),
score,
}
}
fn v(applies: bool, relevance: f32) -> JudgeVerdict {
JudgeVerdict { applies, relevance }
}
struct MockReviewLlm {
response: Result<String, ()>,
calls: Mutex<Vec<(String, String)>>,
}
impl MockReviewLlm {
fn ok(body: &str) -> Self {
Self {
response: Ok(body.to_owned()),
calls: Mutex::new(Vec::new()),
}
}
fn erroring() -> Self {
Self {
response: Err(()),
calls: Mutex::new(Vec::new()),
}
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
fn last_user_prompt(&self) -> Option<String> {
self.calls.lock().unwrap().last().map(|(_, u)| u.clone())
}
}
#[async_trait::async_trait]
impl ReviewLlm for MockReviewLlm {
async fn chat(&self, system: &str, user: &str) -> crate::Result<String> {
self.calls
.lock()
.unwrap()
.push((system.to_owned(), user.to_owned()));
self.response
.clone()
.map_err(|()| crate::errors::CoreError::Internal("mock failure".into()))
}
}
#[test]
fn parse_direct_json_array() {
let map = parse_judge_response(
r#"[{"id":0,"applies":true,"relevance":0.9},{"id":1,"applies":false,"relevance":0.1}]"#,
)
.unwrap();
assert_eq!(map.get(&0).copied(), Some(v(true, 0.9)));
assert_eq!(map.get(&1).copied(), Some(v(false, 0.1)));
}
#[test]
fn parse_fenced_code_block() {
let text = "Here you go:\n```json\n[{\"id\":0,\"applies\":false,\"relevance\":0.2}]\n```\n";
let map = parse_judge_response(text).unwrap();
assert_eq!(map.get(&0).copied(), Some(v(false, 0.2)));
}
#[test]
fn parse_bracket_scan_with_prose_around() {
let text = "I think the answer is [{\"id\":2,\"applies\":true,\"relevance\":0.7}] overall.";
let map = parse_judge_response(text).unwrap();
assert_eq!(map.get(&2).copied(), Some(v(true, 0.7)));
}
#[test]
fn parse_missing_applies_defaults_to_keep() {
let map = parse_judge_response(r#"[{"id":0,"relevance":0.5}]"#).unwrap();
assert_eq!(map.get(&0).copied(), Some(v(true, 0.5)));
}
#[test]
fn parse_missing_relevance_derives_from_applies() {
let map =
parse_judge_response(r#"[{"id":0,"applies":false},{"id":1,"applies":true}]"#).unwrap();
assert_eq!(map.get(&0).copied(), Some(v(false, 0.0)));
assert_eq!(map.get(&1).copied(), Some(v(true, 1.0)));
}
#[test]
fn parse_clamps_out_of_range_relevance() {
let map = parse_judge_response(r#"[{"id":0,"applies":true,"relevance":4.2}]"#).unwrap();
assert!((map.get(&0).unwrap().relevance - 1.0).abs() < f32::EPSILON);
}
#[test]
fn parse_garbage_returns_none() {
assert!(parse_judge_response("not json at all").is_none());
assert!(parse_judge_response("").is_none());
}
#[test]
fn parse_empty_array_is_some_empty_map() {
let map = parse_judge_response("[]").unwrap();
assert!(map.is_empty());
}
#[test]
fn parse_skips_entries_without_id() {
let map = parse_judge_response(r#"[{"applies":false},{"id":1,"applies":true}]"#).unwrap();
assert!(!map.contains_key(&0));
assert_eq!(map.get(&1).copied(), Some(v(true, 1.0)));
}
#[test]
fn filter_drops_only_non_applicable_rules() {
let rules = vec![
rule("a", "Rule A", "body a", 0.9),
rule("b", "Rule B", "body b", 0.8),
rule("c", "Rule C", "body c", 0.7),
];
let mut verdicts = HashMap::new();
verdicts.insert(0, v(true, 0.9)); verdicts.insert(1, v(false, 0.1)); verdicts.insert(2, v(true, 0.6));
let out = judge_filter_rules(rules, &verdicts);
let ids: Vec<&str> = out.iter().map(|r| r.source_id.as_str()).collect();
assert_eq!(ids, vec!["a", "c"], "only the non-applicable rule b drops");
}
#[test]
fn filter_keeps_rules_the_judge_did_not_score() {
let rules = vec![
rule("a", "Rule A", "body a", 0.9),
rule("b", "Rule B", "body b", 0.8),
rule("c", "Rule C", "body c", 0.7),
];
let mut verdicts = HashMap::new();
verdicts.insert(0, v(false, 0.0));
let out = judge_filter_rules(rules, &verdicts);
let ids: Vec<&str> = out.iter().map(|r| r.source_id.as_str()).collect();
assert_eq!(
ids,
vec!["b", "c"],
"unscored rules survive, scored drop applies"
);
}
#[test]
fn filter_preserves_pool_order() {
let rules = vec![
rule("a", "A", "x", 0.5),
rule("b", "B", "y", 0.5),
rule("c", "C", "z", 0.5),
rule("d", "D", "w", 0.5),
];
let mut verdicts = HashMap::new();
verdicts.insert(1, v(false, 0.0)); let out = judge_filter_rules(rules, &verdicts);
let ids: Vec<&str> = out.iter().map(|r| r.source_id.as_str()).collect();
assert_eq!(ids, vec!["a", "c", "d"]);
}
#[test]
fn filter_never_returns_empty_pool_when_all_dropped() {
let rules = vec![rule("a", "A", "x", 0.5), rule("b", "B", "y", 0.5)];
let mut verdicts = HashMap::new();
verdicts.insert(0, v(false, 0.0));
verdicts.insert(1, v(false, 0.0));
let out = judge_filter_rules(rules, &verdicts);
let ids: Vec<&str> = out.iter().map(|r| r.source_id.as_str()).collect();
assert_eq!(ids, vec!["a", "b"], "all-dropped falls back to full pool");
}
#[test]
fn filter_empty_input_is_empty() {
let out = judge_filter_rules(Vec::new(), &HashMap::new());
assert!(out.is_empty());
}
#[tokio::test]
async fn judge_disabled_is_noop_and_makes_no_call() {
let llm = MockReviewLlm::ok("[]");
let rules = vec![rule("a", "A", "x", 0.5), rule("b", "B", "y", 0.5)];
let out = run_applicability_judge(&llm, false, "some diff", rules.clone()).await;
assert_eq!(out.len(), rules.len());
assert_eq!(llm.call_count(), 0, "disabled must not hit the provider");
}
#[tokio::test]
async fn judge_filters_pool_on_valid_response() {
let llm = MockReviewLlm::ok(
r#"[{"id":0,"applies":true,"relevance":0.9},{"id":1,"applies":false,"relevance":0.1},{"id":2,"applies":true,"relevance":0.8}]"#,
);
let rules = vec![
rule("keep1", "Keep 1", "relevant body", 0.9),
rule("drop", "Drop", "irrelevant body", 0.8),
rule("keep2", "Keep 2", "relevant body 2", 0.7),
];
let out = run_applicability_judge(&llm, true, "diff body", rules).await;
let ids: Vec<&str> = out.iter().map(|r| r.source_id.as_str()).collect();
assert_eq!(ids, vec!["keep1", "keep2"]);
assert_eq!(llm.call_count(), 1);
}
#[tokio::test]
async fn judge_keeps_all_on_llm_error() {
let llm = MockReviewLlm::erroring();
let rules = vec![
rule("a", "A", "x", 0.5),
rule("b", "B", "y", 0.5),
rule("c", "C", "z", 0.5),
];
let out = run_applicability_judge(&llm, true, "diff", rules.clone()).await;
assert_eq!(out.len(), rules.len(), "LLM error => untouched pool");
}
#[tokio::test]
async fn judge_keeps_all_on_unparseable_response() {
let llm = MockReviewLlm::ok("the model rambled and returned no json");
let rules = vec![rule("a", "A", "x", 0.5), rule("b", "B", "y", 0.5)];
let out = run_applicability_judge(&llm, true, "diff", rules.clone()).await;
assert_eq!(out.len(), rules.len(), "parse failure => untouched pool");
}
#[tokio::test]
async fn judge_skips_single_rule_pool() {
let llm = MockReviewLlm::ok(r#"[{"id":0,"applies":false}]"#);
let rules = vec![rule("only", "Only", "x", 0.9)];
let out = run_applicability_judge(&llm, true, "diff", rules).await;
assert_eq!(out.len(), 1);
assert_eq!(
llm.call_count(),
0,
"single-rule pool must not call provider"
);
}
#[tokio::test]
async fn judge_skips_empty_diff() {
let llm = MockReviewLlm::ok(r#"[{"id":0,"applies":false}]"#);
let rules = vec![rule("a", "A", "x", 0.5), rule("b", "B", "y", 0.5)];
let out = run_applicability_judge(&llm, true, "", rules).await;
assert_eq!(out.len(), 2);
assert_eq!(llm.call_count(), 0, "empty diff must not call provider");
}
#[tokio::test]
async fn judge_all_dropped_falls_back_to_full_pool() {
let llm = MockReviewLlm::ok(
r#"[{"id":0,"applies":false,"relevance":0.0},{"id":1,"applies":false,"relevance":0.0}]"#,
);
let rules = vec![rule("a", "A", "x", 0.5), rule("b", "B", "y", 0.5)];
let out = run_applicability_judge(&llm, true, "diff", rules).await;
let ids: Vec<&str> = out.iter().map(|r| r.source_id.as_str()).collect();
assert_eq!(ids, vec!["a", "b"], "all-dropped => full pool, never empty");
}
#[tokio::test]
async fn judge_prompt_enumerates_rules_with_indices() {
let llm = MockReviewLlm::ok("[]"); let rules = vec![
rule(
"a",
"Pin Actions to SHAs",
"Always pin GitHub Actions.",
0.9,
),
rule("b", "Avoid unwrap", "Do not unwrap in library code.", 0.8),
];
let _ = run_applicability_judge(&llm, true, "diff text here", rules).await;
assert_eq!(llm.call_count(), 1);
let prompt = llm.last_user_prompt().unwrap();
assert!(prompt.contains("- id: 0"), "rule 0 enumerated");
assert!(prompt.contains("- id: 1"), "rule 1 enumerated");
assert!(
prompt.contains("Pin Actions to SHAs"),
"title carried into prompt"
);
assert!(
prompt.contains("diff text here"),
"diff carried into prompt"
);
}
}