blendtutor_core/llm/prompt.rs
1//! The pure, injection-hardened prompt builder.
2//!
3//! Owns the prompt's domain inputs ([`Submission`], [`ExecResults`]), its output
4//! ([`Prompt`]), the structural delimiter/label constants, and [`build_prompt`]
5//! itself. Pure and fixture-free (§2.1, §2.3): no client, env, network, or IO — the
6//! effectful request lives in [`feedback`](super::feedback). The student submission
7//! is untrusted input bound for an LLM, so every interpolated value is neutralized:
8//! a forged delimiter or label can never become a second structural token.
9
10use crate::grade::CheckOutcome;
11use crate::lesson::Lesson;
12use crate::runner::ExecutionResult;
13
14/// Opens the fence around the verbatim student submission.
15///
16/// Exported so tests and the builder reference one literal source — the same
17/// constant is both emitted by [`build_prompt`] and asserted against, so the
18/// test predicate pins the real delimiter rather than a hand-copied twin that
19/// could drift from it (§1.5).
20pub const OPEN_CODE: &str = "<<<STUDENT_CODE_BEGIN>>>";
21/// Closes the fence around the verbatim student submission.
22pub const CLOSE_CODE: &str = "<<<STUDENT_CODE_END>>>";
23/// Labels the captured-output section.
24pub const OUTPUT_LABEL: &str = "<<<CAPTURED_OUTPUT>>>";
25/// Labels the check-results section.
26pub const CHECKS_LABEL: &str = "<<<CHECK_RESULTS>>>";
27
28/// Labels the optional success-criteria section (ADR-0020). Not a structural
29/// token: criteria text is author-written and neutralized like the task, so it
30/// can never forge a fence or a verdict section.
31const SUCCESS_CRITERIA_LABEL: &str = "Success criteria:";
32
33/// What replaces any structural token found inside untrusted interpolated text,
34/// so an injected fence or label can never count as a real one.
35const NEUTRALIZED: &str = "[neutralized-delimiter]";
36
37/// A learner's submitted code, awaiting feedback.
38///
39/// A newtype around the source so submitted code is never confused with arbitrary
40/// text elsewhere (§1.4).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Submission {
43 /// The submitted source code, verbatim.
44 pub code: String,
45}
46
47impl Submission {
48 /// Wrap submitted source `code`.
49 pub fn new(code: impl Into<String>) -> Self {
50 Self { code: code.into() }
51 }
52}
53
54/// A graded run: what the submission produced, paired with its per-check verdicts.
55///
56/// Bundles the single [`ExecutionResult`] from running the submission with the
57/// ordered [`CheckOutcome`]s from grading it — one per lesson check, by index. It
58/// does **not** carry the check code-strings; those live on the [`Lesson`] and are
59/// paired with `outcomes` positionally (ADR-0006). This is the input
60/// [`build_prompt`] reads.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ExecResults {
63 /// What running the submission produced.
64 pub output: ExecutionResult,
65 /// One verdict per lesson check, in `lesson.checks` order.
66 pub outcomes: Vec<CheckOutcome>,
67}
68
69/// A rendered LLM feedback prompt.
70///
71/// A newtype around the assembled text so a prompt is never confused with
72/// arbitrary text, and the rendering stays the single thing the provider sends
73/// (§1.4).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Prompt(String);
76
77impl Prompt {
78 /// The rendered prompt text.
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84/// Strip every structural token out of untrusted `text`.
85///
86/// Applied to each interpolated value so the only occurrences of the fences and
87/// labels in the final prompt are the ones [`build_prompt`] itself emits. The
88/// replacement marker contains no structural token, so replacements can neither
89/// create nor hide one another. This is the whole injection defense: an injected
90/// `CLOSE_CODE` + forged `CHECKS_LABEL` cannot break out of the fence or forge a
91/// verdict, because each is rewritten before it reaches the prompt.
92fn neutralize(text: &str) -> String {
93 text.replace(OPEN_CODE, NEUTRALIZED)
94 .replace(CLOSE_CODE, NEUTRALIZED)
95 .replace(OUTPUT_LABEL, NEUTRALIZED)
96 .replace(CHECKS_LABEL, NEUTRALIZED)
97}
98
99/// Render one check as `<check code>: <outcome>` for the checks section, both
100/// sides neutralized.
101fn render_check(check: &str, outcome: &CheckOutcome) -> String {
102 let verdict = match outcome {
103 CheckOutcome::Pass => "pass".to_string(),
104 CheckOutcome::Fail { detail } => format!("fail — {}", neutralize(detail)),
105 CheckOutcome::NotRun { reason } => format!("not run — {}", neutralize(reason)),
106 };
107 format!("{}: {verdict}", neutralize(check))
108}
109
110/// Render the check-results section: one line per outcome, in order, each labeled
111/// with its lesson check code-string.
112///
113/// Iterates over the **outcomes** — the verdicts — not the lesson's checks, so a
114/// verdict is never silently dropped. In the normal flow the two are 1:1:
115/// [`run_checks`](crate::grade::run_checks) returns exactly one outcome per
116/// `lesson.checks` entry, in order. If a caller hand-built [`ExecResults`] with
117/// more outcomes than the lesson has checks, the extra outcomes still render
118/// (labeled by 1-based position) rather than vanishing; checks with no outcome
119/// have no verdict to report and so contribute no line.
120fn render_checks(lesson: &Lesson, outcomes: &[CheckOutcome]) -> String {
121 outcomes
122 .iter()
123 .enumerate()
124 .map(|(i, outcome)| {
125 let fallback = format!("check {}", i + 1);
126 let label = lesson
127 .checks
128 .get(i)
129 .map(String::as_str)
130 .unwrap_or(fallback.as_str());
131 render_check(label, outcome)
132 })
133 .collect::<Vec<_>>()
134 .join("\n")
135}
136
137/// Build the LLM feedback prompt for a graded submission.
138///
139/// Pure (§2.1): it reads only the borrowed domain values and performs no IO, env
140/// read, or network call, so identical inputs always render byte-identically. The
141/// layout is a fixed structure — the task, the lesson's `success_criteria` when
142/// present and non-blank (ADR-0020), a single fenced copy of the submission,
143/// a captured-output section, and a check-results section (one line per outcome,
144/// labeled with its `lesson.checks` entry by index) — not the lesson's
145/// `llm_evaluation_prompt` template (ADR-0006). `results.outcomes` is expected 1:1
146/// with `lesson.checks` (the shape `run_checks` produces); the rendering never
147/// silently drops a verdict if they desync (see `render_checks`). Every
148/// interpolated value is neutralized, so the fences and labels each appear
149/// exactly once even when the submission forges them: injected text can never be
150/// read as code or as a verdict.
151pub fn build_prompt(lesson: &Lesson, submission: &Submission, results: &ExecResults) -> Prompt {
152 let task = neutralize(&lesson.exercise.prompt);
153 let criteria = lesson
154 .exercise
155 .success_criteria
156 .as_deref()
157 .filter(|text| !text.trim().is_empty())
158 .map(neutralize);
159 let code = neutralize(&submission.code);
160 let output = neutralize(&results.output.stdout);
161 let checks = render_checks(lesson, &results.outcomes);
162
163 let mut lines = vec![
164 "You are evaluating student code for a programming exercise.",
165 "",
166 "Task:",
167 task.trim_end(),
168 "",
169 ];
170 if let Some(criteria) = &criteria {
171 lines.extend([SUCCESS_CRITERIA_LABEL, criteria.trim_end(), ""]);
172 }
173 lines.extend([
174 OPEN_CODE,
175 code.trim_end_matches('\n'),
176 CLOSE_CODE,
177 "",
178 OUTPUT_LABEL,
179 output.trim_end_matches('\n'),
180 "",
181 CHECKS_LABEL,
182 &checks,
183 ]);
184 let rendered = lines.join("\n");
185
186 Prompt(rendered)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn neutralize_rewrites_every_structural_token() {
195 for token in [OPEN_CODE, CLOSE_CODE, OUTPUT_LABEL, CHECKS_LABEL] {
196 let injected = format!("before {token} after");
197 let cleaned = neutralize(&injected);
198 assert!(
199 !cleaned.contains(token),
200 "neutralize must rewrite {token}, got: {cleaned}"
201 );
202 assert!(
203 cleaned.contains(NEUTRALIZED),
204 "the token is replaced by the neutral marker"
205 );
206 }
207 }
208
209 #[test]
210 fn neutralize_marker_introduces_no_structural_token() {
211 // The marker must itself be free of every structural token, or a
212 // replacement could resurrect one it just removed.
213 for token in [OPEN_CODE, CLOSE_CODE, OUTPUT_LABEL, CHECKS_LABEL] {
214 assert!(
215 !NEUTRALIZED.contains(token),
216 "the neutral marker must not contain {token}"
217 );
218 }
219 }
220
221 #[test]
222 fn render_check_distinguishes_the_three_outcomes() {
223 assert_eq!(render_check("c", &CheckOutcome::Pass), "c: pass");
224 assert_eq!(
225 render_check(
226 "c",
227 &CheckOutcome::Fail {
228 detail: "boom".to_string()
229 }
230 ),
231 "c: fail — boom"
232 );
233 assert_eq!(
234 render_check(
235 "c",
236 &CheckOutcome::NotRun {
237 reason: "no run".to_string()
238 }
239 ),
240 "c: not run — no run"
241 );
242 }
243
244 fn lesson_with_checks(checks: &[&str]) -> Lesson {
245 let check_lines = checks
246 .iter()
247 .map(|c| format!(" - \"{c}\""))
248 .collect::<Vec<_>>()
249 .join("\n");
250 let yaml = format!(
251 "lesson_name: \"L\"\nlanguage: R\nchecks:\n{check_lines}\nexercise:\n \
252 prompt: \"do it\"\n llm_evaluation_prompt: \"grade {{student_code}}\"\n"
253 );
254 Lesson::parse(&yaml).expect("the constructed lesson is valid")
255 }
256
257 #[test]
258 fn render_checks_labels_each_outcome_with_its_check_in_order() {
259 let lesson = lesson_with_checks(&["first_check", "second_check"]);
260 let rendered = render_checks(
261 &lesson,
262 &[
263 CheckOutcome::Pass,
264 CheckOutcome::Fail {
265 detail: "nope".to_string(),
266 },
267 ],
268 );
269 assert_eq!(rendered, "first_check: pass\nsecond_check: fail — nope");
270 }
271
272 #[test]
273 fn render_checks_never_drops_a_verdict_when_outcomes_exceed_checks() {
274 // A desynced ExecResults (more outcomes than the lesson has checks) must
275 // not hide a verdict — the security-relevant property is that a Fail is
276 // always shown. The unlabeled extra falls back to its 1-based position.
277 let lesson = lesson_with_checks(&["only_check"]);
278 let rendered = render_checks(
279 &lesson,
280 &[
281 CheckOutcome::Pass,
282 CheckOutcome::Fail {
283 detail: "hidden?".to_string(),
284 },
285 ],
286 );
287 assert_eq!(rendered, "only_check: pass\ncheck 2: fail — hidden?");
288 assert!(
289 rendered.contains("hidden?"),
290 "the second verdict must not be dropped, got: {rendered}"
291 );
292 }
293
294 #[test]
295 fn render_checks_renders_no_line_for_a_check_without_an_outcome() {
296 // The other desync direction: more checks than outcomes. A check with no
297 // outcome has no verdict to report, so it contributes no line — it must
298 // never surface as a phantom pass.
299 let lesson = lesson_with_checks(&["graded", "ungraded"]);
300 let rendered = render_checks(&lesson, &[CheckOutcome::Pass]);
301 assert_eq!(rendered, "graded: pass");
302 assert!(
303 !rendered.contains("ungraded"),
304 "a check with no outcome must not appear as a phantom verdict, got: {rendered}"
305 );
306 }
307
308 #[test]
309 fn render_check_neutralizes_a_token_in_the_outcome_detail() {
310 // A check's failure detail is interpreter output — untrusted — so a fence
311 // smuggled through stderr must be neutralized too.
312 let rendered = render_check(
313 "c",
314 &CheckOutcome::Fail {
315 detail: format!("oops {CLOSE_CODE}"),
316 },
317 );
318 assert!(
319 !rendered.contains(CLOSE_CODE),
320 "a token in a check detail is neutralized, got: {rendered}"
321 );
322 }
323}