Files
coursebank/src/export/practice.rs
T
alexm cfe8a3216c
Pipeline / check (pull_request) Successful in 3m8s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
fix: rendering in typst
2026-09-22 00:19:40 -04:00

680 lines
23 KiB
Rust

// 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<Variant> {
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<String> {
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<String> {
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<String> {
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<String> = 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<String> = 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<String> = 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<String> = 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<String> {
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}");
}
}