differential_engine/grouping/payload.rs
1//! The prompt: instructions, a path, and the class ids.
2//!
3//! It used to be the whole context. One block per shape class — a header, four
4//! removed and four added lines from the **exemplar hunk only**, six basenames
5//! — capped at 90,000 characters, with anything past the cap silently dropped
6//! into the back-fill. So a class of nine hunks got rated "read one, trust the
7//! rest" on the evidence of one hunk, and a large change lost classes to a
8//! character count.
9//!
10//! Now the model fetches (ADR 0022). The engine writes the pre-group document
11//! and the prompt says where it is, how to ask it questions, and how to read
12//! the diff text with `git diff`. Nothing is truncated, because nothing is
13//! sent.
14//!
15//! The class id list stays in the prompt: it is about a kilobyte for two
16//! hundred classes, and it means a model whose fetches all fail still knows the
17//! exact id set. A weak grouping is recoverable; a hallucinated one wastes the
18//! audit's time telling us so.
19//!
20//! **The prompt is prose, so it lives in prose.** `prompt.txt` beside this file
21//! is the whole text, read exactly as the model reads it. Editing it is a diff
22//! of sentences rather than a diff of Rust string literals, which is what a
23//! reviewer of a prompt actually needs to see. Cargo tracks `include_str!`, so
24//! an edit still rebuilds, and `src/` ships with the crate.
25
26use super::ClassInfo;
27
28/// Feeds the cache key: bump on ANY change to the prompt text or the shape of
29/// what the model can fetch, or cached groupings would silently mix prompt
30/// generations.
31pub const PROMPT_VERSION: u32 = 6;
32
33/// The prompt, with `{{…}}` placeholders for the five run-specific values.
34const PROMPT: &str = include_str!("prompt.txt");
35
36/// Instructions, the commands, and the class id list.
37///
38/// Takes the executable, the artefact path and the range rather than the
39/// document: which binary the model can run, where it should read, and which
40/// two revisions `git diff` compares are all the caller's decisions. This
41/// function only writes them down — and the backend's tool allowlist must be
42/// built from the same `fetch`, or the model is told to run a command it is not
43/// permitted to run.
44///
45/// The range is spelled into a whole `git diff` command rather than described.
46/// The reader is an agent with a terminal: a command it can run beats an
47/// instruction it has to assemble.
48///
49/// `base` and `head` are whatever `schema::Source` holds, which for a staged or
50/// worktree review is a raw tree oid rather than a commit. `git diff` takes any
51/// two tree-ish arguments, so the command is the same one either way — which is
52/// the reason to pass the two strings through rather than reconstruct a range
53/// spelling here.
54///
55/// Substitution is five `replace` calls, not a template engine. A dependency
56/// for five calls would be a dependency for five calls.
57pub fn build_prompt(
58 offered: &[&ClassInfo],
59 fetch: &str,
60 artefact: &str,
61 base: &str,
62 head: &str,
63) -> String {
64 let mut order: Vec<&&ClassInfo> = offered.iter().collect();
65 order.sort_by_key(|c| (usize::MAX - c.n_hunks, c.exemplar));
66 let ids: Vec<&str> = order.iter().map(|c| c.id.as_str()).collect();
67
68 PROMPT
69 .replace("{{FETCH}}", fetch)
70 .replace("{{DOC}}", artefact)
71 .replace("{{BASE}}", base)
72 .replace("{{HEAD}}", head)
73 .replace("{{CLASS_IDS}}", &ids.join(" "))
74}