// SPDX-License-Identifier: Prosperity-3.0.0 // Copyright Scientific Computing Studio // Source: https://git.scient.ing/education/coursebank //! Rendering an assessment as a Quarto worksheet a student can work through, and //! a matching solutions document they can learn from. //! //! This is the path that does not go through Canvas. You assemble a homework, //! quiz, or practice set the same way you assemble an exam, then render it as two //! `.qmd` files: [`Variant::Worksheet`] holds the questions and nothing else, and //! [`Variant::Solutions`] holds the same questions with the key marked, the worked //! reasoning, the rubric for anything open-ended, and where to read again. A //! student with neither the Canvas quiz nor the printed exam can still practice //! from the worksheet and check themselves against the solutions. //! //! A worksheet never contains the answer. It is built only from stems and //! options, and the option letters are the printed positions, so the document has //! nothing in it to leak: not a `correct` flag, not a solution, not a rationale. //! [`Variant::Solutions`] is a separate render from the same input. //! //! Option order comes from the form's seed. When a form shuffles, both //! documents relabel to the printed order through //! [`select::option_order`], so a worksheet handed to //! a student who saw form B agrees with the form B solutions. //! //! Everything a solution shows is authored: the model answer, the explanation, the //! per-option notes, the rubric, and the review citations. Nothing is invented //! here. A question with an empty [`crate::item::Solution`] renders its key and //! stops, which is a visible cue to go finish writing it. use crate::assessment::{AssessmentFile, Form, Placement}; use crate::catalog::Catalog; use crate::course::{CourseFile, Reference}; use crate::error::Result; use crate::item::{Choice, Citation, Item}; use crate::markup; use crate::select; /// Which of the two documents to render. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Variant { /// Questions only, for a student to work through. #[default] Worksheet, /// Questions with the key, worked solutions, rubric, and readings. Solutions, } impl Variant { /// Both documents, in the order they are usually written. pub const ALL: [Variant; 2] = [Variant::Worksheet, Variant::Solutions]; /// The token used on the command line and in a file name. pub fn as_str(self) -> &'static str { match self { Variant::Worksheet => "worksheet", Variant::Solutions => "solutions", } } /// The suffix a generated file name carries, e.g. `-solutions`. pub fn suffix(self) -> &'static str { match self { Variant::Worksheet => "", Variant::Solutions => "-solutions", } } /// The word for this document in a title. fn title_word(self) -> &'static str { match self { Variant::Worksheet => "Questions", Variant::Solutions => "Solutions", } } /// Parses a `--variant` value. /// /// # Arguments /// /// * `name` - the token, case insensitive; `questions` is accepted for the /// worksheet and `key` for the solutions, since those are what people type. /// /// # Returns /// /// The variant. /// /// # Errors /// /// Returns [`crate::error::Error::Usage`] naming the valid tokens. pub fn parse(name: &str) -> Result { match name.trim().to_ascii_lowercase().as_str() { "worksheet" | "questions" | "q" => Ok(Variant::Worksheet), "solutions" | "solution" | "key" => Ok(Variant::Solutions), other => Err(crate::error::Error::usage(format!( "unknown practice document `{other}`; use worksheet or solutions" ))), } } } /// What to render. #[derive(Debug, Clone)] pub struct Options { /// Which form's ordering to use. Defaults to an unshuffled form. pub form: Form, /// Which document. pub variant: Variant, /// Leave vertical space after each question on the worksheet for a written /// answer. Ignored for the solutions document. pub answer_space: bool, } impl Default for Options { fn default() -> Options { Options { form: Form { id: "A".to_string(), seed: 0, shuffle_items: false, shuffle_options: false, }, variant: Variant::Worksheet, answer_space: true, } } } impl Options { /// Options for one variant on one form. /// /// # Arguments /// /// * `variant` - which document. /// * `form` - the form whose ordering to use. /// /// # Returns /// /// The options, with the answer space on. pub fn new(variant: Variant, form: Form) -> Options { Options { form, variant, answer_space: true, } } } /// Renders the questions-only worksheet. /// /// # Arguments /// /// * `catalog` - the loaded course. /// * `record` - the assessment record. /// * `form` - the form whose ordering to use. /// /// # Returns /// /// The Quarto Markdown, ending in a newline. /// /// # Errors /// /// Returns [`crate::error::Error::Unresolved`] when a placement references a /// missing item. pub fn worksheet(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result { render( catalog, record, &Options::new(Variant::Worksheet, form.clone()), ) } /// Renders the solutions document. /// /// # Arguments /// /// * `catalog` - the loaded course. /// * `record` - the assessment record. /// * `form` - the form whose ordering to use. /// /// # Returns /// /// The Quarto Markdown, ending in a newline. /// /// # Errors /// /// As [`worksheet`]. pub fn solutions(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result { render( catalog, record, &Options::new(Variant::Solutions, form.clone()), ) } /// Renders one document. /// /// # Arguments /// /// * `catalog` - the loaded course. /// * `record` - the assessment record. /// * `opts` - what to render. /// /// # Returns /// /// The Quarto Markdown, ending in a newline. /// /// # Errors /// /// Returns [`crate::error::Error::Unresolved`] when a placement references a /// missing item. pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result { let course = &catalog.course; let mut out = front_matter(course, record, opts.variant); if let Some(instructions) = &record.assessment.instructions { out.push_str(&markup::to_markdown(instructions)); out.push_str("\n\n"); } // One shared stimulus is printed once, above the first question that uses it, // so a testlet reads as a block rather than repeating the vignette per item. let mut printed_stimulus: Option = None; let layout = select::layout(record, &opts.form); for (position, placement) in layout.iter().filter(|p| !p.dropped).enumerate() { let entry = catalog.require(&placement.item)?; let item = &entry.item; let number = position + 1; if let Some(stimulus_id) = &item.stimulus { if printed_stimulus.as_deref() != Some(stimulus_id.as_str()) { if let Some(stimulus) = course.stimuli.get(stimulus_id) { out.push_str("::: {.stimulus}\n\n"); out.push_str(&markup::to_markdown(&stimulus.body)); out.push_str("\n\n:::\n\n"); } printed_stimulus = Some(stimulus_id.clone()); } } match opts.variant { Variant::Worksheet => worksheet_question( &mut out, number, placement, item, &opts.form, opts.answer_space, ), Variant::Solutions => { solution_question(&mut out, number, placement, item, &opts.form, course) } } } Ok(out) } /// The Quarto YAML front matter. fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String { // Both documents name themselves. Two PDFs called "Homework 1" are // indistinguishable in a downloads folder, which is how a solutions copy // gets posted in place of the worksheet. let title = format!("{}: {}", record.assessment.title, variant.title_word()); let subtitle = format!("{} · {}", course.course.code, course.course.title); let mut out = String::from("---\n"); out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title))); out.push_str(&format!("subtitle: \"{}\"\n", yaml_quote(&subtitle))); if let Some(date) = record.assessment.date { out.push_str(&format!("date: \"{date}\"\n")); } out.push_str("format:\n html:\n toc: false\n number-sections: false\n"); out.push_str("---\n\n"); out } /// One question on the worksheet: stem, options in printed order, no answer. fn worksheet_question( out: &mut String, number: usize, placement: &Placement, item: &Item, form: &Form, answer_space: bool, ) { out.push_str(&heading(number, placement)); out.push_str(&markup::to_markdown(&item.stem)); out.push_str("\n\n"); if item.has_options() { let ordered = ordered_options(item, form, &placement.item); for (position, source) in ordered.iter().enumerate() { out.push_str(&format!( "{}. {}\n", letter(position), markup::to_markdown(&source.text) )); } out.push('\n'); } else if answer_space { // A place to write, sized by the theme, present only when asked for. out.push_str("::: {.answer-space}\n:::\n\n"); } } /// One question in the solutions document: stem, key, worked reasoning, rubric, /// and where to look again. fn solution_question( out: &mut String, number: usize, placement: &Placement, item: &Item, form: &Form, course: &CourseFile, ) { out.push_str(&heading(number, placement)); out.push_str(&meta_line(placement, item)); out.push_str(&markup::to_markdown(&item.stem)); out.push_str("\n\n"); if item.has_options() { let ordered = ordered_options(item, form, &placement.item); for (position, source) in ordered.iter().enumerate() { let mark = if source.correct { " ✓" } else { "" }; let note = source .student_text() .map(|t| format!(": {}", markup::to_markdown(t))) .unwrap_or_default(); out.push_str(&format!( "{}. {}{mark}{note}\n", letter(position), markup::to_markdown(&source.text) )); } out.push('\n'); } solution_body(out, item); objectives_line(out, item, course); review_line(out, item, course); out.push('\n'); } /// The model answer, explanation, rubric, and accepted answers, when present. fn solution_body(out: &mut String, item: &Item) { let Some(solution) = item.solution.as_ref().filter(|s| !s.is_empty()) else { if !item.has_options() { // An open-response question with no written solution is unfinished, and // saying so in the document is more useful than a silent blank. out.push_str("_No solution written yet._\n\n"); } return; }; if let Some(answer) = &solution.model_answer { out.push_str(&format!( "**Model answer.** {}\n\n", markup::to_markdown(answer) )); } if let Some(explanation) = &solution.explanation { out.push_str(&markup::to_markdown(explanation)); out.push_str("\n\n"); } if !solution.rubric.is_empty() { out.push_str("**Rubric**\n\n"); for criterion in &solution.rubric { let points = criterion .points .map(|p| format!(" ({} pt)", trim_number(p))) .unwrap_or_default(); out.push_str(&format!( "- {}{points}\n", markup::to_markdown(&criterion.description) )); } out.push('\n'); } if !solution.accepted.is_empty() { let joined: Vec = solution .accepted .iter() .map(|a| markup::to_markdown(a)) .collect(); out.push_str(&format!("**Accepted answers:** {}\n\n", joined.join("; "))); } } /// The `Tests:` line naming the objectives this item measures. fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) { if item.learning_targets.is_empty() { return; } let texts: Vec = item .learning_targets .iter() .map(|id| course.text_for(id)) .collect(); out.push_str(&format!("**Tests:** {}\n\n", texts.join("; "))); } /// The `Review:` line, resolving each citation to a short label, linked when a URL /// resolves. fn review_line(out: &mut String, item: &Item, course: &CourseFile) { let Some(solution) = item.solution.as_ref() else { return; }; if solution.review.is_empty() { return; } let cites: Vec = solution.review.iter().map(|c| cite(course, c)).collect(); out.push_str(&format!("**Review:** {}\n\n", cites.join("; "))); } /// Resolves one citation to Markdown, mirroring the lecture reading style /// `` `KKW` [§6.1](url) ``. fn cite(course: &CourseFile, citation: &Citation) -> String { if let Some(text) = &citation.text { if citation.reference.is_none() { return text.clone(); } } let Some(key) = &citation.reference else { return citation.display(); }; let Some(reference) = course.references.get(key) else { return citation.display(); }; let label = reference.label.as_deref().unwrap_or(key); let locator = citation.locator.as_deref().unwrap_or(""); match resolve_url(citation, reference) { Some(url) if !locator.is_empty() => format!("`{label}` [{locator}]({url})"), Some(url) => format!("`{label}` [{}]({url})", reference.title), None if !locator.is_empty() => format!("`{label}` {locator}"), None => format!("`{label}`"), } } /// The URL for a citation: its own `url`, else the reference `base_url` joined with /// the citation `path`. fn resolve_url(citation: &Citation, reference: &Reference) -> Option { if let Some(url) = &citation.url { return Some(url.clone()); } let path = citation.path.as_deref()?; let base = reference.base_url.as_deref()?; Some(match (base.ends_with('/'), path.starts_with('/')) { (true, true) => format!("{base}{}", &path[1..]), (false, false) => format!("{base}/{path}"), _ => format!("{base}{path}"), }) } /// The `## Question N` heading, marking a bonus item. fn heading(number: usize, placement: &Placement) -> String { let bonus = if placement.bonus { " (bonus)" } else { "" }; format!("## Question {number}{bonus}\n\n") } /// The italic level-and-points line under a solutions heading. fn meta_line(placement: &Placement, item: &Item) -> String { let level = placement.level.unwrap_or(item.level); let mut parts = vec![format!("Level {} ({})", level.code(), level.name())]; if let Some(points) = placement.points { parts.push(format!("{} point(s)", trim_number(points))); } format!("_{}_\n\n", parts.join(" · ")) } /// The options in the order the form prints them. /// /// Salted with the item's global id, the same value the Typst and QTI exports use, /// so a worksheet built for form B lists options in the order that form's paper and /// its Canvas quiz do. fn ordered_options<'a>(item: &'a Item, form: &Form, uid: &str) -> Vec<&'a Choice> { select::option_order(form, uid, item.options.len()) .into_iter() .map(|i| &item.options[i]) .collect() } /// The printed letter for a zero-based position. fn letter(position: usize) -> char { (b'A' + (position as u8 % 26)) as char } /// Formats a point value without a trailing `.0`. fn trim_number(value: f64) -> String { if value.fract() == 0.0 { format!("{}", value as i64) } else { let s = format!("{value:.2}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } } /// Escapes a double quote for a YAML double-quoted scalar. fn yaml_quote(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } #[cfg(test)] mod tests { use super::*; use crate::assessment::{Assessment, Kind, Platform}; /// Writes a course and a bank to a temp directory and loads them, the same way /// the catalog tests do, so this exercises only public API. The `tag` keeps each /// test in its own directory, so tests running in parallel do not clobber a /// shared `course.yaml`. fn catalog(tag: &str) -> Catalog { let dir = std::env::temp_dir().join(format!("cb-practice-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("banks")).unwrap(); std::fs::write( dir.join("course.yaml"), r#" course: { code: BIOSC 1000, title: Biochemistry, term: 2026f } references: kkw: label: KKW title: The molecules of life base_url: https://example.org/kkw/ lectures: L1.1: { title: Enthalpy } learning_objectives: lo-enthalpy: text: Define enthalpy and explain the constant-pressure result. lectures: [L1.1] order: 1 "#, ) .unwrap(); std::fs::write( dir.join("banks").join("l11.yaml"), r#" bank: { id: l11, title: L1.1 } items: - id: q-enthalpy-001 status: draft level: 1 stem: At constant pressure, the heat exchanged equals which quantity? learning_targets: [lo-enthalpy] options: - { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." } - { id: B, text: "the internal energy change", misconception: "ignores expansion work" } - { id: C, text: "zero" } solution: explanation: "Because H = U + PV, at constant P the P dV term is the expansion work, so q_p equals the change in H." review: - { ref: kkw, locator: "§6.4", path: "6/A/#4" } - id: q-enthalpy-op-001 status: draft level: 2 format: open_response stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change. learning_targets: [lo-enthalpy] solution: model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H." rubric: - { description: "states H = U + PV", points: 1 } - { description: "identifies q_p with the enthalpy change", points: 1 } review: - { ref: kkw, locator: "§6.4", path: "6/A/#4" } "#, ) .unwrap(); Catalog::load(&dir).expect("catalog loads") } fn record() -> AssessmentFile { AssessmentFile { schema_version: "1.0".into(), assessment: Assessment { id: "hw-1".into(), title: "Homework 1".into(), term: None, kind: Kind::Homework, date: None, platform: Platform::Canvas, minutes_allowed: None, attempts: None, shuffle: None, scoring_policy: None, instructions: None, notes: None, }, blueprint: None, forms: Vec::new(), items: vec![ Placement { number: 1, item: "l11::q-enthalpy-001".into(), version: None, fingerprint: None, points: Some(1.0), bonus: false, key: vec!["A".into()], level: None, learning_targets: Vec::new(), credit_overrides: Default::default(), dropped: false, dropped_as: None, }, Placement { number: 2, item: "l11::q-enthalpy-op-001".into(), version: None, fingerprint: None, points: Some(2.0), bonus: false, key: Vec::new(), level: None, learning_targets: Vec::new(), credit_overrides: Default::default(), dropped: false, dropped_as: None, }, ], } } #[test] fn worksheet_withholds_the_answer() { let md = worksheet(&catalog("worksheet"), &record(), &Options::default().form).expect("renders"); assert!(md.contains("## Question 1")); assert!(md.contains("A. the enthalpy change")); // Nothing that reveals the key or the reasoning. assert!(!md.contains('✓'), "no check marks on the worksheet:\n{md}"); assert!(!md.contains("Model answer"), "no model answer:\n{md}"); assert!(!md.contains("P dV"), "no explanation:\n{md}"); assert!(!md.contains("Rubric")); // The open-response question leaves room to write. assert!(md.contains("answer-space")); } #[test] fn solutions_show_key_reasoning_rubric_and_review() { let md = solutions(&catalog("solutions"), &record(), &Options::default().form).expect("renders"); assert!(md.contains("A. the enthalpy change ✓")); assert!(md.contains("the internal energy change: ignores expansion work")); assert!(md.contains("**Model answer.**")); assert!(md.contains("H = U + PV")); assert!(md.contains("**Rubric**")); assert!(md.contains("states H = U + PV (1 pt)")); assert!(md.contains("Tests:** Define enthalpy")); // The review citation resolves to the label and a link. assert!( md.contains("`KKW` [§6.4](https://example.org/kkw/6/A/#4)"), "{md}" ); } #[test] fn front_matter_titles_each_document() { let ws = worksheet( &catalog("front-matter-ws"), &record(), &Options::default().form, ) .expect("renders"); assert!(ws.contains("title: \"Homework 1: Questions\""), "{ws}"); let sol = solutions( &catalog("front-matter-sol"), &record(), &Options::default().form, ) .expect("renders"); assert!(sol.contains("title: \"Homework 1: Solutions\""), "{sol}"); // Neither document may title itself with the bare assessment name: two // PDFs called "Homework 1" are indistinguishable once downloaded, and // the pair that gets confused is the one with the answers in it. assert!(!ws.contains("title: \"Homework 1\""), "{ws}"); assert!(!sol.contains("title: \"Homework 1\""), "{sol}"); } }