Skip to main content

visual_rubric/
presets.rs

1//! Question preset interface for standardized rubric questions.
2//!
3//! A "preset" is a named set of one or more rubric questions that end-users
4//! can select via `--preset <name>` instead of providing `--question` on
5//! every invocation.  A preset may also carry a standard system prompt that
6//! is used when the caller does not override `--system-prompt`.
7//!
8//! Presets are deliberately generic — they describe a *kind* of screenshot
9//! (an application UI, a website install section, a manuscript figure), not
10//! a specific project.  Projects layer their own context on top with
11//! [`extend`], or implement [`QuestionPreset`] delegating to a registered
12//! preset.  Register new presets in [`all`].
13
14use std::fmt;
15
16/// JSON verdict schema demanded by every preset system prompt.
17macro_rules! verdict_schema {
18    () => {
19        "{ \"verdict\": \"pass\" | \"fail\", \"reason\": string, \"anomalies\": string[] }"
20    };
21}
22
23/// A named preset that produces one or more standardized rubric questions.
24///
25/// Implement this trait to register a preset that end-users can select
26/// via `--preset <name>` instead of providing `--question` directly.
27pub trait QuestionPreset {
28    /// Unique identifier used as the `--preset` argument value.
29    fn name(&self) -> &'static str;
30    /// The question text(s) this preset produces.
31    fn questions(&self) -> Vec<String>;
32    /// Standard system prompt paired with this preset's questions.
33    ///
34    /// Used when the caller does not provide an explicit system prompt.
35    fn system_prompt(&self) -> Option<&'static str> {
36        None
37    }
38}
39
40/// Appends caller-specific context to a preset question or system prompt.
41///
42/// Presets are generic; use this to layer project context on top, such as
43/// naming the application under review or adding extra fail criteria.
44#[must_use]
45pub fn extend(base: &str, extension: &str) -> String {
46    format!("{base}\n\n{extension}")
47}
48
49/// Question for [`UiRegression`]: scenario-agnostic UI defect check.
50pub const UI_REGRESSION_QUESTION: &str = "\
51Checklist: the visible UI is complete and readable. Fail for text clipped or overflowing \
52its container, overlapping interactive elements, missing or blank regions where content should \
53appear, illegible contrast, or visibly broken layout.";
54
55/// System prompt for [`UiRegression`].
56///
57/// Also the crate-wide [`crate::DEFAULT_SYSTEM_PROMPT`].
58pub const UI_REGRESSION_SYSTEM_PROMPT: &str = concat!(
59    "\
60You are a UI regression auditor. \
61You will be shown one screenshot and asked a specific question. Reply with strict \
62JSON matching this schema and nothing else:\n",
63    verdict_schema!(),
64    "\nFail criteria: text clipped or overflowing its container, overlapping interactive \
65elements, missing/blank regions where content should appear, illegible contrast, \
66visibly broken layout. Cosmetic differences from previous runs are NOT failures \
67unless they make the UI worse by the criteria above."
68);
69
70/// Question for [`WebsiteInstall`]: website install-section UX audit.
71pub const WEBSITE_INSTALL_QUESTION: &str = "\
72Does this page make the install section easy to find, choose from, and act on without layout or \
73responsive UX defects?";
74
75/// System prompt for [`WebsiteInstall`].
76pub const WEBSITE_INSTALL_SYSTEM_PROMPT: &str = concat!(
77    "\
78You are auditing a software project website install section. Focus on install flow clarity, \
79scanability, heading hierarchy, call-to-action placement, prerequisite visibility, command-copy \
80ergonomics, responsive layout, text clipping, overlapping UI, and whether the next step is obvious. \
81Reply with strict JSON matching this schema and nothing else:\n",
82    verdict_schema!()
83);
84
85/// Question for [`ManuscriptFigure`]: publication QA for manuscript figures.
86pub const MANUSCRIPT_FIGURE_QUESTION: &str =
87    "Does this manuscript figure asset pass publication visual QA?";
88
89/// System prompt for [`ManuscriptFigure`].
90pub const MANUSCRIPT_FIGURE_SYSTEM_PROMPT: &str = concat!(
91    "\
92You are auditing scientific manuscript figure PNGs at their rendered print size. \
93Reply with strict JSON only:\n",
94    verdict_schema!(),
95    "\nDo not call tools, inspect files, run commands, or browse. Evaluate only the supplied image. \
96Fail if any of these are visible: clipped text or labels, overlapping labels or \
97plot marks, illegible axis/legend/caption text, poor contrast for important text, \
98blank or placeholder panels, broken legends, missing axes where axes are expected, \
99malformed TikZ/rasterization artifacts, cropped arrows/connectors, or composite \
100assembly errors such as missing panels, wrong panel order, or inconsistent panel \
101lettering. When an asset fails, phrase anomalies so they identify the reusable \
102failure class when possible, such as bottom legend clipping, left label margin, \
103heatmap label density, annotation collision, TikZ crop margin, or composite \
104assembly. Do not fail for minor aesthetic preferences when the asset is readable \
105and complete."
106);
107
108/// Preset `ui-regression`: generic defect check for one application UI
109/// screenshot without a scenario-specific checklist.
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
111pub struct UiRegression;
112
113impl QuestionPreset for UiRegression {
114    fn name(&self) -> &'static str {
115        "ui-regression"
116    }
117
118    fn questions(&self) -> Vec<String> {
119        vec![UI_REGRESSION_QUESTION.to_owned()]
120    }
121
122    fn system_prompt(&self) -> Option<&'static str> {
123        Some(UI_REGRESSION_SYSTEM_PROMPT)
124    }
125}
126
127/// Preset `website-install`: install-section UX audit for project websites.
128#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
129pub struct WebsiteInstall;
130
131impl QuestionPreset for WebsiteInstall {
132    fn name(&self) -> &'static str {
133        "website-install"
134    }
135
136    fn questions(&self) -> Vec<String> {
137        vec![WEBSITE_INSTALL_QUESTION.to_owned()]
138    }
139
140    fn system_prompt(&self) -> Option<&'static str> {
141        Some(WEBSITE_INSTALL_SYSTEM_PROMPT)
142    }
143}
144
145/// Preset `manuscript-figure`: publication QA for scientific manuscript
146/// figure PNGs.
147#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
148pub struct ManuscriptFigure;
149
150impl QuestionPreset for ManuscriptFigure {
151    fn name(&self) -> &'static str {
152        "manuscript-figure"
153    }
154
155    fn questions(&self) -> Vec<String> {
156        vec![MANUSCRIPT_FIGURE_QUESTION.to_owned()]
157    }
158
159    fn system_prompt(&self) -> Option<&'static str> {
160        Some(MANUSCRIPT_FIGURE_SYSTEM_PROMPT)
161    }
162}
163
164/// Errors from preset name resolution.
165#[derive(Clone, Debug, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum PresetError {
168    /// The named preset was not found.
169    NotFound {
170        /// The unrecognised preset name.
171        name: String,
172    },
173}
174
175impl fmt::Display for PresetError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::NotFound { name } => {
179                let available = all().map(QuestionPreset::name).join(", ");
180                write!(
181                    f,
182                    "unknown question preset {name:?} (available: {available})"
183                )
184            }
185        }
186    }
187}
188
189impl std::error::Error for PresetError {}
190
191/// Returns every registered preset.
192#[must_use]
193pub fn all() -> [&'static dyn QuestionPreset; 3] {
194    [&UiRegression, &WebsiteInstall, &ManuscriptFigure]
195}
196
197/// Resolves a preset name to the registered preset.
198///
199/// # Errors
200///
201/// Returns [`PresetError::NotFound`] when no preset has that name.
202pub fn find(name: &str) -> Result<&'static dyn QuestionPreset, PresetError> {
203    all()
204        .into_iter()
205        .find(|preset| preset.name() == name)
206        .ok_or_else(|| PresetError::NotFound {
207            name: name.to_owned(),
208        })
209}
210
211/// Resolves a preset name to its question text(s).
212///
213/// # Errors
214///
215/// Returns [`PresetError::NotFound`] when no preset has that name.
216pub fn resolve(name: &str) -> Result<Vec<String>, PresetError> {
217    find(name).map(QuestionPreset::questions)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn preset_names_are_unique() {
226        let mut names: Vec<_> = all().map(QuestionPreset::name).to_vec();
227        names.sort_unstable();
228        names.dedup();
229        assert_eq!(names.len(), all().len());
230    }
231
232    #[test]
233    fn resolves_each_registered_preset() {
234        for preset in all() {
235            let questions = resolve(preset.name()).expect("registered preset resolves");
236            assert_eq!(questions, preset.questions());
237            assert!(!questions.is_empty());
238        }
239    }
240
241    #[test]
242    fn registered_presets_carry_system_prompts_with_verdict_schema() {
243        for preset in all() {
244            let prompt = preset.system_prompt().expect("preset has a system prompt");
245            assert!(
246                prompt.contains(verdict_schema!()),
247                "{} prompt demands the JSON verdict schema",
248                preset.name()
249            );
250        }
251    }
252
253    #[test]
254    fn preset_texts_stay_project_agnostic() {
255        for preset in all() {
256            for text in preset
257                .questions()
258                .iter()
259                .map(String::as_str)
260                .chain(preset.system_prompt())
261            {
262                for project in ["plinth", "Chessbender", "SynDB"] {
263                    assert!(
264                        !text.to_lowercase().contains(&project.to_lowercase()),
265                        "{} preset must not mention {project}",
266                        preset.name()
267                    );
268                }
269            }
270        }
271    }
272
273    #[test]
274    fn extend_appends_context_paragraph() {
275        let extended = extend(UI_REGRESSION_SYSTEM_PROMPT, "The screenshots show MyApp.");
276        assert!(extended.starts_with(UI_REGRESSION_SYSTEM_PROMPT));
277        assert!(extended.ends_with("\n\nThe screenshots show MyApp."));
278    }
279
280    #[test]
281    fn unknown_preset_error_lists_available_names() {
282        let error = resolve("nope").expect_err("unknown preset must fail");
283        let message = error.to_string();
284        assert!(message.contains("\"nope\""));
285        assert!(message.contains("ui-regression"));
286        assert!(message.contains("website-install"));
287        assert!(message.contains("manuscript-figure"));
288    }
289}