Skip to main content

difflore_core/review/
prompts.rs

1use super::{ReviewIssueRecord, ReviewPerspective};
2use crate::context::assembler::PastVerdictSection;
3use crate::context::types::PastVerdict;
4
5// Segmented prompt for prompt cache reuse.
6
7/// A single team rule in the canonical form used when producing the
8/// cacheable team-rules digest. This is deliberately minimal: the point
9/// of the digest is to be deterministic / hash-stable across reviews so
10/// that an upstream Anthropic `cache_control` hint can reuse the prefix.
11#[derive(Debug, Clone)]
12pub struct TeamRuleDigest {
13    pub id: String,
14    pub content: String,
15}
16
17/// System prompt split into a cacheable stable prefix and a per-review
18/// dynamic suffix. The stable prefix is intended to be reused across
19/// multiple reviews from the same team (identical perspective + rules +
20/// repo context) so providers that support prompt caching (e.g. Anthropic
21/// `cache_control: ephemeral`) can skip re-tokenising it.
22///
23/// Concatenating `stable_prefix + dynamic_suffix` yields a conventional
24/// flat system prompt — `build_system_prompt` relies on this property for
25/// byte-identical backward compatibility.
26#[derive(Debug, Clone)]
27pub struct SegmentedPrompt {
28    /// Cacheable, hash-stable across reviews for the same team:
29    /// base instructions → perspective addendum → sorted team rules →
30    /// repo context facts.
31    pub stable_prefix: String,
32    /// Per-review content: past verdicts → current diff → user instructions.
33    pub dynamic_suffix: String,
34}
35
36/// Hard-coded base instructions for the review system prompt. Kept as a
37/// constant so the compatibility shim and `build_segmented_prompt` share the
38/// exact same bytes.
39const 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
56/// Render the team-rules digest section. Rules are sorted by `id` so the
57/// resulting string is deterministic across review runs — this is what
58/// makes the stable prefix hash-stable and therefore cacheable.
59///
60/// Returns an empty string when `rules` is empty so callers that have no
61/// rules produce a prefix that is byte-identical to the flat prompt.
62pub(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
82/// Render the optional repo context facts section. Empty input is treated the
83/// same as `None` to preserve byte-identical reassembly.
84pub(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
96/// Render the per-review dynamic suffix. Empty inputs produce an empty
97/// string so the compatibility shim can reassemble byte-identical output.
98///
99/// `past_verdicts` is review-memory recall injected at the front of the dynamic
100/// segment, so the LLM reads prior verdicts before the current diff. When it is
101/// `None` or empty the section is omitted entirely.
102pub(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    // Past verdicts come first in the dynamic segment so the LLM reads
121    // prior decisions before the current diff.
122    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
138/// Build a `SegmentedPrompt` split into a hash-stable cacheable prefix
139/// and a per-review dynamic suffix. See [`SegmentedPrompt`] for layout.
140///
141/// Ordering (top → bottom):
142/// * `stable_prefix`: base instructions → perspective addendum → team
143///   rules digest (sorted by id) → repo context facts.
144/// * `dynamic_suffix`: past verdicts → current diff → user instructions.
145///
146/// Concatenating the two halves yields the same flat prompt that
147/// `build_system_prompt` reassembles for compatibility.
148pub 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    // 1. Base instructions (hardcoded, perspective-agnostic).
157    let mut stable_prefix = String::with_capacity(REVIEW_BASE_INSTRUCTIONS.len() + 1024);
158    stable_prefix.push_str(REVIEW_BASE_INSTRUCTIONS);
159
160    // 2. Perspective addendum (if any).
161    if let Some(p) = perspective {
162        stable_prefix.push_str(p.system_prompt_addendum());
163    }
164
165    // 3. Team rules digest (deterministic / hash-stable).
166    stable_prefix.push_str(&render_team_rules_digest(team_rules));
167
168    // 4. Repo context facts.
169    stable_prefix.push_str(&render_repo_context_section(repo_context_facts));
170
171    // Dynamic suffix: past verdicts → current diff → user instructions.
172    let dynamic_suffix = render_dynamic_suffix(diff, user_instructions, past_verdicts);
173
174    SegmentedPrompt {
175        stable_prefix,
176        dynamic_suffix,
177    }
178}
179
180/// Build the system prompt for review check.
181///
182/// When `perspective` is `Some`, the perspective-specific addendum is
183/// appended to the base prompt. When `None`, the returned string is
184/// byte-identical to the flat single-pass prompt.
185///
186/// Delegates to `build_segmented_prompt` with empty extras and reassembles the
187/// two halves.
188#[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
194/// Build the user prompt with diff + matched rules
195pub(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
221/// System prompt used by the self-check verification pass. Short and
222/// strict so the cheap model doesn't hallucinate new issues.
223pub(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
231/// Build the verification user-prompt: the diff (trimmed) + the
232/// candidate issues enumerated with stable `id` indices so the model's
233/// response can be matched back deterministically.
234pub(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}