Skip to main content

cerno_core/
prompt.rs

1//! Prompt construction.
2//!
3//! The instructions are in English even though the state and the question routinely are not.
4//! That is deliberate: the answer is a single letter either way, so the instruction language
5//! never reaches the output, and small instruct-tuned models follow English formatting
6//! instructions more reliably than translated ones. The content stays in whatever language the
7//! caller wrote it in.
8
9/// Told to the model once, ahead of the question.
10pub const SYSTEM: &str = "You answer exactly one question about the context you are given. \
11Reply with a single capital letter naming your answer, and nothing else. \
12No explanation, no punctuation, no other words.";
13
14/// Build the user turn: context, question, lettered options, and a closing reminder.
15///
16/// The reminder after the options repeats the instruction because it is the text nearest the
17/// generation point, which is where a small model is most likely to still be following it.
18pub fn user_turn(state: &str, question: Option<&str>, options: &[(&str, &str)]) -> String {
19    let mut out = String::with_capacity(state.len() + 256);
20
21    out.push_str("CONTEXT:\n");
22    out.push_str(state.trim());
23    out.push_str("\n\n");
24
25    if let Some(question) = question {
26        out.push_str("QUESTION: ");
27        push_one_line(&mut out, question);
28        out.push_str("\n\n");
29    }
30
31    for (label, text) in options {
32        out.push_str(label);
33        out.push_str(") ");
34        push_one_line(&mut out, text);
35        out.push('\n');
36    }
37
38    out.push_str("\nAnswer with one letter only.");
39    out
40}
41
42/// Append `text` with every run of whitespace, line breaks included, collapsed to one space.
43///
44/// The question and each option are one line, always. A line break inside either would start
45/// what looks to the model like an option: `"x\nB) y"` offered an option B that was never asked.
46/// The state keeps its lines; it sits above the question, where nothing reads it as a label.
47fn push_one_line(out: &mut String, text: &str) {
48    for (i, word) in text.split_whitespace().enumerate() {
49        if i > 0 {
50            out.push(' ');
51        }
52        out.push_str(word);
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn renders_context_question_and_lettered_options() {
62        let prompt = user_turn(
63            "Printer is jammed.",
64            Some("Which team?"),
65            &[("A", "IT"), ("B", "Facility")],
66        );
67
68        assert_eq!(
69            prompt,
70            "CONTEXT:\nPrinter is jammed.\n\n\
71             QUESTION: Which team?\n\n\
72             A) IT\nB) Facility\n\n\
73             Answer with one letter only."
74        );
75    }
76
77    /// A Choice whose options speak for themselves needs no question line.
78    #[test]
79    fn omits_the_question_line_when_there_is_no_question() {
80        let prompt = user_turn("Ticket text.", None, &[("A", "Yes"), ("B", "No")]);
81
82        assert!(!prompt.contains("QUESTION:"));
83        assert!(prompt.contains("CONTEXT:\nTicket text."));
84    }
85
86    /// Stray whitespace in caller input must not shift the layout the model was tuned against.
87    #[test]
88    fn trims_caller_whitespace() {
89        let prompt = user_turn("  padded  \n", Some("  Urgent?  "), &[("A", "Yes")]);
90
91        assert!(prompt.starts_with("CONTEXT:\npadded\n\n"));
92        assert!(prompt.contains("QUESTION: Urgent?\n"));
93    }
94
95    /// An option containing a line break must not forge the next label.
96    #[test]
97    fn an_option_is_always_one_line() {
98        let prompt = user_turn("s", None, &[("A", "x\nB) y"), ("B", "  z  ")]);
99
100        assert!(prompt.contains("A) x B) y\nB) z\n"), "{prompt}");
101        assert_eq!(prompt.matches("\nB) ").count(), 1, "{prompt}");
102    }
103
104    /// The question sits right above the options, so a line break in it can forge one too.
105    #[test]
106    fn the_question_is_always_one_line() {
107        let prompt = user_turn("s", Some("Which?\nC) extra"), &[("A", "x"), ("B", "y")]);
108
109        assert!(
110            prompt.contains("QUESTION: Which? C) extra\n\nA) x\n"),
111            "{prompt}"
112        );
113        assert!(!prompt.contains("\nC) "), "{prompt}");
114    }
115
116    /// The state is free text and keeps its layout; only the lines near the labels are flattened.
117    #[test]
118    fn the_state_keeps_its_lines() {
119        let prompt = user_turn("line one\nline two", None, &[("A", "x")]);
120
121        assert!(
122            prompt.starts_with("CONTEXT:\nline one\nline two\n\n"),
123            "{prompt}"
124        );
125    }
126
127    #[test]
128    fn the_last_line_is_the_reminder() {
129        let prompt = user_turn("s", Some("q"), &[("A", "x"), ("B", "y")]);
130
131        assert!(prompt.ends_with("Answer with one letter only."));
132    }
133}