difflore_core/review/
prompts.rs1use super::{ReviewIssueRecord, ReviewPerspective};
2use crate::context::assembler::PastVerdictSection;
3use crate::context::types::PastVerdict;
4
5#[derive(Debug, Clone)]
12pub struct TeamRuleDigest {
13 pub id: String,
14 pub content: String,
15}
16
17#[derive(Debug, Clone)]
27pub struct SegmentedPrompt {
28 pub stable_prefix: String,
32 pub dynamic_suffix: String,
34}
35
36const REVIEW_BASE_INSTRUCTIONS: &str = r#"You are a code review assistant. Review the provided diff against the given rules and return issues as a JSON array.
40
41Each issue must be a JSON object with these fields:
42- severity: "error" | "warning" | "info"
43- rule: the rule name that was violated
44- ruleId: stable rule ID when the matched rule provides one (optional, string)
45- message: clear description of the issue
46- file: repo-relative path of the affected file as it appears in the diff header (e.g. "src/app.ts" — strip the "a/" or "b/" prefix; REQUIRED for downstream patch generation)
47- line: line number in the diff (optional, number)
48- existingCode: copy the EXACT affected source line(s) verbatim from the diff, without the leading +/- marker (optional, string; helps pinpoint the precise location)
49- suggestion: how to fix it (optional, string)
50
51Matched rules are the user's review memory and should be treated as authoritative review criteria. If the diff directly matches a rule's bad pattern, contradicts a rule's recommendation, or removes code a rule says is required, report that issue even when the change is small or the code still compiles. Do not return [] when a matched rule clearly applies to the diff.
52
53Return ONLY a JSON array. No markdown, no explanation, no code blocks. Just the raw JSON array.
54If no issues are found, return an empty array: []"#;
55
56pub(super) fn render_team_rules_digest(rules: &[TeamRuleDigest]) -> String {
63 if rules.is_empty() {
64 return String::new();
65 }
66 let mut sorted: Vec<&TeamRuleDigest> = rules.iter().collect();
67 sorted.sort_by(|a, b| a.id.cmp(&b.id));
68
69 let mut s = String::new();
70 s.push_str("\n\n## Team Rules Digest\n");
71 for r in sorted {
72 s.push_str("\n- id: ");
73 s.push_str(&r.id);
74 s.push('\n');
75 s.push_str(" content: ");
76 s.push_str(&r.content);
77 s.push('\n');
78 }
79 s
80}
81
82pub(super) fn render_repo_context_section(repo_context_facts: Option<&str>) -> String {
85 match repo_context_facts {
86 Some(facts) if !facts.is_empty() => {
87 let mut s = String::new();
88 s.push_str("\n\n## Repo Context\n");
89 s.push_str(facts);
90 s
91 }
92 _ => String::new(),
93 }
94}
95
96pub(super) fn render_dynamic_suffix(
103 diff: &str,
104 user_instructions: &str,
105 past_verdicts: Option<&[PastVerdict]>,
106) -> String {
107 let has_diff = !diff.is_empty();
108 let has_instructions = !user_instructions.is_empty();
109 let verdicts_rendered = match past_verdicts {
110 Some(v) if !v.is_empty() => PastVerdictSection::new(v.to_vec()).render(),
111 _ => String::new(),
112 };
113 let has_verdicts = !verdicts_rendered.is_empty();
114
115 if !has_diff && !has_instructions && !has_verdicts {
116 return String::new();
117 }
118
119 let mut s = String::new();
120 if has_verdicts {
123 s.push_str("\n\n");
124 s.push_str(verdicts_rendered.trim_end());
125 }
126 if has_diff {
127 s.push_str("\n\n## Current Diff\n```diff\n");
128 s.push_str(diff);
129 s.push_str("\n```");
130 }
131 if has_instructions {
132 s.push_str("\n\n## User Instructions\n");
133 s.push_str(user_instructions);
134 }
135 s
136}
137
138pub fn build_segmented_prompt(
149 perspective: Option<ReviewPerspective>,
150 team_rules: &[TeamRuleDigest],
151 diff: &str,
152 user_instructions: &str,
153 repo_context_facts: Option<&str>,
154 past_verdicts: Option<&[PastVerdict]>,
155) -> SegmentedPrompt {
156 let mut stable_prefix = String::with_capacity(REVIEW_BASE_INSTRUCTIONS.len() + 1024);
158 stable_prefix.push_str(REVIEW_BASE_INSTRUCTIONS);
159
160 if let Some(p) = perspective {
162 stable_prefix.push_str(p.system_prompt_addendum());
163 }
164
165 stable_prefix.push_str(&render_team_rules_digest(team_rules));
167
168 stable_prefix.push_str(&render_repo_context_section(repo_context_facts));
170
171 let dynamic_suffix = render_dynamic_suffix(diff, user_instructions, past_verdicts);
173
174 SegmentedPrompt {
175 stable_prefix,
176 dynamic_suffix,
177 }
178}
179
180#[cfg(test)]
189pub(super) fn build_system_prompt(perspective: Option<ReviewPerspective>) -> String {
190 let seg = build_segmented_prompt(perspective, &[], "", "", None, None);
191 format!("{}{}", seg.stable_prefix, seg.dynamic_suffix)
192}
193
194pub(super) fn build_user_prompt(
196 diff: &str,
197 rules_text: Option<&str>,
198 file_path: Option<&str>,
199) -> String {
200 let mut prompt = String::new();
201
202 if let Some(rules) = rules_text {
203 prompt.push_str("## Review Rules\n\n");
204 prompt.push_str("Each matched rule may include a `Rule ID:` line. When you cite a matched rule, copy that exact value into `ruleId`.\n\n");
205 prompt.push_str("Use these rules as concrete checks against the diff. Prefer one precise issue over [] when a rule directly applies.\n\n");
206 prompt.push_str(rules);
207 prompt.push_str("\n\n");
208 }
209
210 if let Some(path) = file_path {
211 prompt.push_str(&format!("## File: {path}\n\n"));
212 }
213
214 prompt.push_str("## Diff to Review\n\n```diff\n");
215 prompt.push_str(diff);
216 prompt.push_str("\n```\n");
217
218 prompt
219}
220
221pub(super) const VERIFY_SYSTEM_PROMPT: &str = r#"You are a strict code-review verifier. Given the diff and a list of candidate issues, for EACH issue decide whether it is a true positive.
224
225Return ONLY a JSON array. Each element must be an object:
226{"id": <index>, "confidence": <float 0..1>, "verdict": "keep"|"drop", "reason": "<short>"}
227
228Be strict — drop obvious false positives. Keep an issue when the changed line directly matches the cited rule's bad pattern or contradicts the cited rule's recommendation, even if surrounding pre-existing code has similar style. Do NOT invent new issues.
229Return the raw JSON array only, no markdown, no explanation."#;
230
231pub(super) fn build_verify_user_prompt(diff: &str, issues: &[ReviewIssueRecord]) -> String {
235 const DIFF_LIMIT: usize = 8_000;
236 let trimmed = if diff.len() > DIFF_LIMIT {
237 &diff[..DIFF_LIMIT]
238 } else {
239 diff
240 };
241
242 let mut s = String::new();
243 s.push_str("## Diff\n```diff\n");
244 s.push_str(trimmed);
245 s.push_str("\n```\n\n## Candidate issues\n");
246 for (i, issue) in issues.iter().enumerate() {
247 s.push_str(&format!(
248 "- id: {}\n severity: {}\n rule: {}\n file: {}\n line: {}\n message: {}\n suggestion: {}\n",
249 i,
250 issue.severity,
251 issue.rule,
252 issue.file.as_deref().unwrap_or(""),
253 issue.line.map(|n| n.to_string()).unwrap_or_default(),
254 issue.message,
255 issue.suggestion.as_deref().unwrap_or(""),
256 ));
257 }
258 s
259}
260
261pub(super) const SUMMARY_SYSTEM_PROMPT: &str = r#"You are a code-review summarizer. Given a diff, produce a concise one-line PR summary plus per-file intent descriptions.
262
263Return ONLY a JSON object with this exact shape:
264{
265 "oneLineSummary": "<one sentence>",
266 "walkthroughByFile": [
267 {"file": "<path>", "intent": "<one sentence describing what this file's change does>"}
268 ]
269}
270No markdown, no code blocks, no extra commentary."#;
271
272pub(super) fn build_summary_user_prompt(diff: &str, files: &[String]) -> String {
273 const DIFF_LIMIT: usize = 8_000;
274 let trimmed = if diff.len() > DIFF_LIMIT {
275 &diff[..DIFF_LIMIT]
276 } else {
277 diff
278 };
279 let mut s = String::new();
280 s.push_str("## Files touched\n");
281 for f in files {
282 s.push_str("- ");
283 s.push_str(f);
284 s.push('\n');
285 }
286 s.push_str("\n## Diff\n```diff\n");
287 s.push_str(trimmed);
288 s.push_str("\n```\n");
289 s
290}