1use std::fmt;
15
16macro_rules! verdict_schema {
18 () => {
19 "{ \"verdict\": \"pass\" | \"fail\", \"reason\": string, \"anomalies\": string[] }"
20 };
21}
22
23pub trait QuestionPreset {
28 fn name(&self) -> &'static str;
30 fn questions(&self) -> Vec<String>;
32 fn system_prompt(&self) -> Option<&'static str> {
36 None
37 }
38}
39
40#[must_use]
45pub fn extend(base: &str, extension: &str) -> String {
46 format!("{base}\n\n{extension}")
47}
48
49pub 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
55pub 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
70pub 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
75pub 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
85pub const MANUSCRIPT_FIGURE_QUESTION: &str =
87 "Does this manuscript figure asset pass publication visual QA?";
88
89pub 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#[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum PresetError {
168 NotFound {
170 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#[must_use]
193pub fn all() -> [&'static dyn QuestionPreset; 3] {
194 [&UiRegression, &WebsiteInstall, &ManuscriptFigure]
195}
196
197pub 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
211pub 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}