diff --git a/.gitignore b/.gitignore index d650d90..5a6fc24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ preview +scratch /dist/ /THIRD-PARTY-LICENSES.txt diff --git a/src/analysis/irt.rs b/src/analysis/irt.rs index 5a30bda..872b79d 100644 --- a/src/analysis/irt.rs +++ b/src/analysis/irt.rs @@ -444,7 +444,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit { for iteration in 0..opts.max_iterations { iterations = iteration + 1; - // ---- E step: expected counts at each quadrature point ---- + // --- E step: expected counts at each quadrature point ---- // Counts are accumulated per item rather than globally, so an item // administered to only some examinees is not charged for the others. let mut n_kj = vec![vec![0.0f64; j_count]; n_quad]; @@ -473,7 +473,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit { } } - // ---- M step: one two-parameter Newton solve per item ---- + // --- M step: one two-parameter Newton solve per item ---- let mut delta = 0.0f64; for j in 0..j_count { let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect(); @@ -529,7 +529,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit { )); } - // ---- Standard errors and per-item notes ---- + // --- Standard errors and per-item notes ---- let (grid_final, weight_final) = (grid.clone(), base_weight.clone()); let mut p_grid = vec![vec![0.0f64; j_count]; n_quad]; for k in 0..n_quad { @@ -601,7 +601,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit { }); } - // ---- Abilities, expected a posteriori ---- + // --- Abilities, expected a posteriori ---- let mut abilities = Vec::with_capacity(n); let mut log_likelihood = 0.0f64; for i in 0..n { diff --git a/src/authoring/jsonschema.rs b/src/authoring/jsonschema.rs index 8d4e030..3d9ce29 100644 --- a/src/authoring/jsonschema.rs +++ b/src/authoring/jsonschema.rs @@ -571,6 +571,77 @@ fn asset_schema() -> Value { }) } +/// The worked solution, and for an open-response item how it is graded. +fn solution_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "description": "The answer, the reasoning, and the rubric. Rendered in the solutions \ + document and the answer key, never in a question paper.", + "properties": { + "model_answer": { + "type": "string", + "description": "For an open-response item, the response a full-credit student \ + writes; for a choice item, an optional one-line statement of the key." + }, + "explanation": { + "type": "string", + "description": "The worked reasoning a student learns from. The body of the \ + solutions entry." + }, + "rubric": { "type": "array", "items": rubric_criterion_schema() }, + "accepted": { + "type": "array", + "items": { "type": "string" }, + "description": "Responses a short constructed answer is accepted as." + }, + "review": { + "type": "array", + "items": citation_schema(), + "description": "Where to look again after missing this item." + } + } + }) +} + +/// One rubric line for an open-response item. +fn rubric_criterion_schema() -> Value { + json!({ + "type": "object", + "required": ["description"], + "additionalProperties": false, + "properties": { + "description": text("What earns the points on this line."), + "points": { "type": "number", "minimum": 0.0 } + } + }) +} + +/// A citation into the reference registry, written as an object or a bare string. +fn citation_schema() -> Value { + json!({ + "oneOf": [ + { "type": "string", "description": "A citation, unparsed." }, + citation_mapping_schema() + ] + }) +} + +/// The object form of a citation. +fn citation_mapping_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "ref": text("Citation key into `references`."), + "locator": { "type": "string", "description": "Where inside the work: §6.1, pp. 4-9." }, + "path": { "type": "string", "description": "Joined to the reference base_url." }, + "url": { "type": "string" }, + "text": { "type": "string" } + } + }) +} + /// The schema for authored design intent. fn design_schema() -> Value { json!({ @@ -738,9 +809,10 @@ fn item_identity_properties() -> Value { "cognitive_process": cognitive_process(), "format": { "type": "string", - "enum": ["single_best_answer", "multiple_response", "true_false"], - "description": "single_best_answer requires exactly one keyed option; \ - multiple_response requires at least two." + "enum": ["single_best_answer", "multiple_response", "true_false", "open_response"], + "description": "single_best_answer keys exactly one option; multiple_response keys \ + two or more; open_response takes no options and is graded from its \ + solution." }, "bonus": { "type": "boolean" }, "points": { "type": "number", "exclusiveMinimum": 0.0 }, @@ -767,8 +839,10 @@ fn item_content_properties() -> Value { "type": "array", "minItems": 2, "maxItems": 8, + "description": "Absent for an open_response item; at least two for any choice format.", "items": option_schema() }, + "solution": solution_schema(), "learning_objectives": string_array( "Objective ids this item measures. Reports aggregate on these, so an item with none \ contributes to nothing." @@ -807,7 +881,7 @@ fn item_schema() -> Value { } json!({ "type": "object", - "required": ["id", "level", "stem", "options"], + "required": ["id", "level", "stem"], "additionalProperties": false, "properties": Value::Object(properties) }) diff --git a/src/authoring/lint.rs b/src/authoring/lint.rs index 040c613..6b78b45 100644 --- a/src/authoring/lint.rs +++ b/src/authoring/lint.rs @@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec = stem.split_whitespace().collect(); @@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec = it.options.iter().filter(|o| o.correct).collect(); let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect(); @@ -659,7 +659,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec Vec Vec { if e.item.status == Status::Retired { continue; } + // An open-response item carries no options, so it is neither part of the + // count norm nor able to deviate from it. Leaving it out keeps a bank of + // four-option questions from reporting every essay as an odd count. + if !e.item.has_options() { + continue; + } by_bank.entry(e.bank.as_str()).or_default().push(e); } diff --git a/src/authoring/select.rs b/src/authoring/select.rs index 09fbff9..2614f98 100644 --- a/src/authoring/select.rs +++ b/src/authoring/select.rs @@ -84,7 +84,7 @@ pub fn select( let seed = blueprint.seed.unwrap_or(0); let mut notes = Vec::new(); - // ------------------------------------------------------------------ pool + // --- pool let eligible: Vec<&crate::catalog::Entry> = catalog .assemblable() .into_iter() @@ -101,7 +101,7 @@ pub fn select( let mut chosen: Vec = Vec::new(); let mut per_bank: BTreeMap = BTreeMap::new(); - // ---------------------------------------------------- objective minimums + // --- objective minimums // Placed first, because a coverage requirement is the constraint most likely // to become unsatisfiable once the level quotas are full. for (objective, needed) in &blueprint.objective_minimums { @@ -140,7 +140,7 @@ pub fn select( } } - // ------------------------------------------------------- level quotas + // --- level quotas let mut scored: Vec = Vec::new(); for (level, want) in &blueprint.level_counts { if *want == 0 { @@ -175,7 +175,7 @@ pub fn select( } } - // ------------------------------------------------------------ bonus items + // --- bonus items let mut bonus: Vec = Vec::new(); for (level, want) in &blueprint.bonus_counts { if *want == 0 { diff --git a/src/cli.rs b/src/cli.rs index ac7a66d..9a6a9ab 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -412,6 +412,29 @@ pub(crate) enum ExportCommand { #[arg(long)] out: Option, }, + /// Write a Quarto worksheet and a matching solutions document. + /// + /// The worksheet holds the questions and nothing else; the solutions document + /// adds the key, the worked reasoning, the rubric, and the readings to revisit. + /// This is the path that does not go through Canvas, so a student can practice + /// from the `.qmd` and check themselves against the solutions. Render each with + /// `quarto render .qmd`. + Practice { + /// Assessment id. + id: String, + /// Which form's ordering to use. + #[arg(long, default_value = "A")] + form: String, + /// Which documents to write; defaults to both. Values: worksheet, solutions. + #[arg(long, value_name = "DOC")] + variant: Vec, + /// Output directory; defaults to build/. + #[arg(long)] + out: Option, + /// Do not leave written-answer space after open-response questions. + #[arg(long)] + no_answer_space: bool, + }, } #[derive(Debug, Subcommand)] diff --git a/src/commands/export.rs b/src/commands/export.rs index ef645b3..ed6eb69 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -12,6 +12,7 @@ use coursebank::assessment::Form; use coursebank::error::{Error, Result}; use coursebank::layout::Layout; +use coursebank::practice; use coursebank::qti; use coursebank::typst; use coursebank::yaml; @@ -168,9 +169,65 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result { println!("wrote {}", path.display()); Ok(Outcome::Ok) } + ExportCommand::Practice { + id, + form, + variant, + out, + no_answer_space, + } => { + let record = load_record(&catalog, id)?; + let form = pick_form(&record, form)?; + let dir = out.clone().unwrap_or(build); + for v in pick_practice_variants(variant)? { + let opts = practice::Options { + form: form.clone(), + variant: v, + answer_space: !no_answer_space, + }; + let text = practice::render(&catalog, &record, &opts)?; + let path = dir.join(format!("{id}-{}{}.qmd", form.id, v.suffix())); + yaml::write_text(&path, &text)?; + println!("wrote {}", path.display()); + } + if !cli.quiet { + println!("\nRender with: quarto render .qmd"); + } + Ok(Outcome::Ok) + } } } +/// Resolves the `--variant` flags for `export practice`, defaulting to both. +/// +/// # Arguments +/// +/// * `names` - the raw flag values, possibly empty. +/// +/// # Returns +/// +/// The documents to write, deduplicated and in canonical order (worksheet first). +/// +/// # Errors +/// +/// Returns [`Error::Usage`] naming the valid tokens. +fn pick_practice_variants(names: &[String]) -> Result> { + if names.is_empty() { + return Ok(practice::Variant::ALL.to_vec()); + } + let mut wanted = Vec::new(); + for name in names { + let variant = practice::Variant::parse(name)?; + if !wanted.contains(&variant) { + wanted.push(variant); + } + } + Ok(practice::Variant::ALL + .into_iter() + .filter(|v| wanted.contains(v)) + .collect()) +} + /// Resolves the `--variant` flags, defaulting to every document. /// /// # Arguments diff --git a/src/export.rs b/src/export.rs index 6de4840..c799049 100644 --- a/src/export.rs +++ b/src/export.rs @@ -8,6 +8,7 @@ //! |:--|:--|:--| //! | [`qti`] | a QTI 1.2 zip | importing into Canvas | //! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet | +//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas | //! | [`report`] | Markdown and HTML | students, and yourself | //! | [`lecture`] | Markdown | the reading list on the course website | //! @@ -22,6 +23,7 @@ //! The instructor report answers "what should I fix?" and holds the item statistics. pub mod lecture; +pub mod practice; pub mod qti; pub mod report; pub mod typst; diff --git a/src/export/practice.rs b/src/export/practice.rs new file mode 100644 index 0000000..b49f288 --- /dev/null +++ b/src/export/practice.rs @@ -0,0 +1,669 @@ +// 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 { + 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_objectives.is_empty() { + return; + } + let texts: Vec = item + .learning_objectives + .iter() + .map(|id| course.objective_text(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_objectives: [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_objectives: [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_objectives: Vec::new(), + credit_overrides: Default::default(), + dropped: false, + }, + 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_objectives: Vec::new(), + credit_overrides: Default::default(), + dropped: false, + }, + ], + } + } + + #[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\"")); + let sol = solutions( + &catalog("front-matter-sol"), + &record(), + &Options::default().form, + ) + .expect("renders"); + assert!(sol.contains("title: \"Homework 1: Solutions\"")); + } +} diff --git a/src/export/qti.rs b/src/export/qti.rs index 99fa46c..4239c2c 100644 --- a/src/export/qti.rs +++ b/src/export/qti.rs @@ -47,9 +47,9 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2"; const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \ http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd"; -// --------------------------------------------------------------------------- +// --- // A very small XML tree -// --------------------------------------------------------------------------- +// --- /// One XML element. #[derive(Debug, Clone)] @@ -155,9 +155,9 @@ fn escape_attr(s: &str) -> String { escape_text(s).replace('"', """) } -// --------------------------------------------------------------------------- +// --- // Package construction -// --------------------------------------------------------------------------- +// --- /// Options for a QTI export. #[derive(Debug, Clone)] @@ -338,6 +338,11 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R /// /// The element. fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node { + // An open-response item is an essay in Canvas: no choices, graded by hand. + if !item.format.has_options() { + return build_essay_item(assessment_id, uid, item, points, opts); + } + let order = select::option_order(&opts.form, uid, item.options.len()); let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect(); @@ -499,6 +504,104 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q node } +/// Builds a Canvas essay item for an open-response question. +/// +/// An essay has no choices and no automatic score: the `` condition leaves +/// grading to the instructor. When feedback is on and a model answer exists, it +/// rides along as general feedback so a student sees it after submitting. +/// +/// This mapping has not been round-tripped through a live Canvas import in this +/// build, so verify it against your instance before relying on it for a graded +/// quiz. +/// +/// # Arguments +/// +/// * `assessment_id` - salts the generated ids. +/// * `uid` - the item's global id. +/// * `item` - the item. +/// * `points` - points as administered. +/// * `opts` - export options. +/// +/// # Returns +/// +/// The element. +fn build_essay_item( + assessment_id: &str, + uid: &str, + item: &Item, + points: f64, + opts: &QtiOptions, +) -> Node { + let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![ + metadata_field("question_type", item.format.qti_type()), + metadata_field("points_possible", &format!("{points:.2}")), + metadata_field("assessment_question_identifierref", &qti_id(uid)), + ])); + + let presentation = Node::new("presentation") + .child(mattext(&format!( + "
{}
", + markup::to_html(&item.stem) + ))) + .child( + Node::new("response_str") + .attr("ident", "response1") + .attr("rcardinality", "Single") + .child( + Node::new("render_fib").child( + Node::new("response_label") + .attr("ident", "answer1") + .attr("rshuffle", "No"), + ), + ), + ); + + // The model answer, shown as general feedback after submission. + let model = item + .solution + .as_ref() + .and_then(|s| s.model_answer.as_deref()) + .filter(|_| opts.include_feedback); + + let mut condition = Node::new("respcondition") + .attr("continue", "No") + .child(Node::new("conditionvar").child(Node::new("other"))); + if model.is_some() { + condition = condition.child( + Node::new("displayfeedback") + .attr("feedbacktype", "Response") + .attr("linkrefid", "general_fb"), + ); + } + + let resprocessing = Node::new("resprocessing") + .child( + Node::new("outcomes").child( + Node::new("decvar") + .attr("maxvalue", "100") + .attr("minvalue", "0") + .attr("varname", "SCORE") + .attr("vartype", "Decimal"), + ), + ) + .child(condition); + + let mut node = Node::new("item") + .attr("ident", qti_id(&format!("{assessment_id}/{uid}"))) + .attr("title", item.display_title()) + .child(item_meta) + .child(presentation) + .child(resprocessing); + + if let Some(text) = model { + node = node.child(Node::new("itemfeedback").attr("ident", "general_fb").child( + Node::new("flow_mat").child(mattext(&format!("
{}
", markup::to_html(text)))), + )); + } + + node +} + /// A `` pair. /// /// # Arguments diff --git a/src/export/report.rs b/src/export/report.rs index f084047..5c10fc0 100644 --- a/src/export/report.rs +++ b/src/export/report.rs @@ -98,7 +98,7 @@ pub fn student( )); out.push_str(&format!("**{}**\n\n", summary.display_name())); - // ---------------------------------------------------------------- score + // --- score out.push_str(&format!( "You scored **{:.1} of {:.1} points ({:.0}%)**", summary.points, summary.points_possible, summary.percent @@ -144,7 +144,7 @@ pub fn student( } } - // ----------------------------------------------------------- objectives + // --- objectives if opts.objectives && !summary.objectives.is_empty() { out.push_str("## What this exam says about each learning objective\n\n"); out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n"); @@ -180,7 +180,7 @@ pub fn student( } } - // --------------------------------------------------------------- levels + // --- levels if opts.levels && summary.levels.len() > 1 { out.push_str("## Kinds of thinking\n\n"); out.push_str( @@ -231,7 +231,7 @@ pub fn student( } } - // ---------------------------------------------------------- what to do + // --- what to do if !summary.focus.is_empty() { out.push_str("## Where to put your time\n\n"); out.push_str("In this order:\n\n"); @@ -274,7 +274,7 @@ pub fn student( out.push_str(&format!("You have clearly got {}.\n\n", list(&refs))); } - // --------------------------------------------------------- missed items + // --- missed items if opts.missed && !summary.missed.is_empty() { out.push_str("## Question by question\n\n"); out.push_str( @@ -356,7 +356,7 @@ pub fn cohort( .unwrap_or_else(|| "date not recorded".into()) )); - // ------------------------------------------------------------- summary + // --- summary let r = &analysis.reliability; out.push_str("## Summary\n\n"); out.push_str(&format!( @@ -408,7 +408,7 @@ pub fn cohort( out.push_str(&format!("> {w}\n\n")); } - // ------------------------------------------------------- revise queue + // --- revise queue let queue = analysis.revise_queue(); out.push_str("## What to revise\n\n"); if queue.is_empty() { @@ -477,7 +477,7 @@ pub fn cohort( } } - // ---------------------------------------------------------- item table + // --- item table out.push_str("## Every item\n\n"); out.push_str( "| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n", @@ -535,7 +535,7 @@ pub fn cohort( } } - // -------------------------------------------------------- class gaps + // --- class gaps out.push_str("## Objectives the class did not meet\n\n"); if cohort.class_gaps.is_empty() { out.push_str("Every assessed objective cleared the mastery threshold.\n\n"); @@ -556,7 +556,7 @@ pub fn cohort( out.push('\n'); } - // ------------------------------------------------------ level coverage + // --- level coverage out.push_str("## Coverage and class performance by level\n\n"); let counts = record.level_counts(); out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n"); @@ -587,7 +587,7 @@ pub fn cohort( let _ = bp; } - // -------------------------------------------------------- archetypes + // --- archetypes if !cohort.archetypes.is_empty() { out.push_str("## Patterns across students\n\n"); out.push_str( diff --git a/src/export/typst/payload.rs b/src/export/typst/payload.rs index dc966ab..718ffbd 100644 --- a/src/export/typst/payload.rs +++ b/src/export/typst/payload.rs @@ -662,11 +662,7 @@ fn calibration(item: &Item) -> Option> { /// The token for a response format. fn format_token(format: Format) -> &'static str { - match format { - Format::SingleBestAnswer => "single_best_answer", - Format::MultipleResponse => "multiple_response", - Format::TrueFalse => "true_false", - } + format.as_str() } /// The token for an administration platform. diff --git a/src/lib.rs b/src/lib.rs index 4517392..766de78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,7 +110,7 @@ pub use data::{canvas, gradescope, responses, store}; pub use analysis::{calibrate, classical, irt, students}; -pub use export::{lecture, qti, report, typst}; +pub use export::{lecture, practice, qti, report, typst}; pub use catalog::Catalog; pub use course::{CourseFile, SCHEMA_VERSION}; diff --git a/src/model/bank.rs b/src/model/bank.rs index b7569a5..a09b58d 100644 --- a/src/model/bank.rs +++ b/src/model/bank.rs @@ -352,10 +352,20 @@ fn validate_item( issues.push("version must be at least 1".into()); } - // --- options ----------------------------------------------------------- - if it.options.len() < 2 { + // --- options --- + // An open-response item takes no options; its answer lives in `solution`. + // Every other format needs at least two things to choose between. + if it.format.has_options() { + if it.options.len() < 2 { + issues.push(format!( + "needs at least 2 options, has {}", + it.options.len() + )); + } + } else if !it.options.is_empty() { issues.push(format!( - "needs at least 2 options, has {}", + "{} items take no options, but {} were given; put the answer in `solution`", + it.format.as_str(), it.options.len() )); } @@ -418,7 +428,7 @@ fn validate_item( } } - // --- key --------------------------------------------------------------- + // --- key ----- let keys = it.key_indices(); match it.format { Format::SingleBestAnswer => { @@ -448,9 +458,14 @@ fn validate_item( issues.push("true_false needs exactly one keyed option".into()); } } + Format::OpenResponse => { + if !keys.is_empty() { + issues.push("open_response items have no keyed option".into()); + } + } } - // --- level and process must agree ------------------------------------- + // --- level and process must agree -------- if let Some(p) = it.cognitive_process { if !it.level.allows(p) { issues.push(format!( @@ -461,7 +476,7 @@ fn validate_item( } } - // --- design plausibility ---------------------------------------------- + // --- design plausibility ----------- if let Some(d) = &it.design { if let Some(x) = d.expected_difficulty { if !(0.0..=1.0).contains(&x) { @@ -479,7 +494,7 @@ fn validate_item( } } - // --- calibration plausibility ----------------------------------------- + // --- calibration plausibility ------ if let Some(c) = &it.calibration { if let Some(p) = c.p_value { if !(0.0..=1.0).contains(&p) { @@ -514,7 +529,7 @@ fn validate_item( } } - // --- history must be coherent ----------------------------------------- + // --- history must be coherent ------ let mut last_version = 0u32; for (i, h) in it.history.iter().enumerate() { if h.version <= last_version { @@ -533,7 +548,7 @@ fn validate_item( )); } - // --- retirement ------------------------------------------------------- + // --- retirement ---- if it.retired.is_some() && it.status != Status::Retired { issues.push(format!( "has a `retired` block but status is `{}`", @@ -541,7 +556,7 @@ fn validate_item( )); } - // --- approval gate ---------------------------------------------------- + // --- approval gate ------- // Approval is what permits an item onto a graded assessment, so it is the // right place to require that the item is fully sourced and designed. if it.status == Status::Approved { @@ -557,9 +572,23 @@ fn validate_item( if it.design.is_none() { issues.push("approved items must carry a design block".into()); } + // An open-response item is graded from its solution, so approving one with + // neither a model answer nor a rubric would leave nothing to mark it by. + if !it.format.has_options() { + let gradeable = it + .solution + .as_ref() + .is_some_and(|s| s.model_answer.is_some() || !s.rubric.is_empty()); + if !gradeable { + issues.push( + "approved open_response items need a solution with a model_answer or a rubric" + .into(), + ); + } + } } - // --- cross-file references -------------------------------------------- + // --- cross-file references --------- if let Some(c) = course { for lo in &it.learning_objectives { match c.learning_objectives.get(lo) { @@ -592,6 +621,15 @@ fn validate_item( issues.push(format!("unknown stimulus `{st}`")); } } + // A citation that names a reference key must name a real one, so a review + // pointer in the solutions document never resolves to nothing. + for citation in it.solution.iter().flat_map(|s| &s.review) { + if let Some(key) = &citation.reference { + if !c.references.contains_key(key) { + issues.push(format!("solution.review cites unknown reference `{key}`")); + } + } + } if let Some(floor) = c.policy.partial_credit_floor_level { for o in &it.options { if o.is_partial() && it.level < floor { @@ -644,6 +682,53 @@ mod tests { assert!(b.validate(None).is_empty(), "{:?}", b.validate(None)); } + #[test] + fn open_response_validates_without_options_and_rejects_them() { + // No options is fine, and no key is required. + let ok = bank( + r#" + - id: q-a-op-001 + status: draft + level: 2 + format: open_response + stem: Explain the first law. + solution: + model_answer: Energy is conserved. +"#, + ); + assert!(ok.validate(None).is_empty(), "{:?}", ok.validate(None)); + + // Giving an open-response item options is the mistake, and so is approving + // one with nothing to grade it by. + let bad = bank( + r#" + - id: q-a-op-002 + status: approved + level: 2 + format: open_response + cognitive_process: explain + learning_objectives: [lo-x] + sources: [{ lecture: L1.1 }] + design: { rationale: r } + stem: Explain the first law. + options: + - { id: A, text: a, correct: true } + - { id: B, text: b } +"#, + ); + let issues = bad.validate(None); + assert!( + issues.iter().any(|i| i.contains("take no options")), + "{issues:?}" + ); + assert!( + issues + .iter() + .any(|i| i.contains("model_answer or a rubric")), + "{issues:?}" + ); + } + #[test] fn catches_missing_and_multiple_keys() { let b = bank( diff --git a/src/model/item.rs b/src/model/item.rs index b1bd877..fb8bc82 100644 --- a/src/model/item.rs +++ b/src/model/item.rs @@ -19,7 +19,11 @@ //! it. That keeps bank files readable and reviewable in a pull request while //! still letting statistics accumulate across terms. -use serde::{Deserialize, Serialize}; +use std::fmt; + +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::date::Date; use crate::hash::fingerprint; @@ -79,8 +83,25 @@ pub struct Item { /// The answer options in canonical order. Shuffling happens at export time /// per form, never here, so the bank stays diffable. + /// + /// Empty for a [`Format::OpenResponse`] item, which is answered in free text + /// and graded from its [`Solution`] instead. A choice format must still supply + /// at least two, which [`crate::bank::BankFile::validate`] enforces; leaving + /// them out is reported there, with every other problem, rather than failing + /// the parse on its own. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, + /// The worked solution: a model answer, an explanation a student can learn + /// from, and, for an open-response item, the rubric it is graded against. + /// + /// This is what the solutions document renders and what a paper answer key + /// prints. It is withheld from any question paper and from the exam payload, + /// the same way an option's `correct` flag is, so a document built for the + /// student cannot leak it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub solution: Option, + /// Objectives this item measures, as ids into the course registry. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub learning_objectives: Vec, @@ -239,6 +260,214 @@ pub struct Source { pub recording_seconds: Option, } +/// A pointer from an item into the course reference registry: where to read more, +/// or what to revisit after missing the item. +/// +/// It holds a citation key and a locator rather than a restated citation, so a +/// reference is written once in `course.yaml` and a changed edition is a single +/// edit. The exporters resolve it against +/// [`crate::course::CourseFile::references`] into a short label such as `KKW §6.1`, +/// linked when the location resolves to a URL. This is the same pointer a lecture +/// [`crate::course::Reading`] uses, kept lean here because an item cites a reading; +/// it does not restate one. +/// +/// A citation may also be written as a bare string, which lands unparsed in `text` +/// and serializes back out as a string, so a bank that stored readings as plain +/// strings keeps loading and round-trips byte-for-byte. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Citation { + /// Citation key into the course reference registry. + pub reference: Option, + /// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`. + pub locator: Option, + /// Appended to the reference's `base_url` to reach this location. + pub path: Option, + /// A full URL, when the location is not under the reference's `base_url`. + pub url: Option, + /// A citation written as a bare string, held unparsed. + pub text: Option, +} + +impl Citation { + /// A short display string that needs no reference lookup. + /// + /// Prefers the unparsed `text`, then the key and locator. A caller that holds + /// the course, such as an exporter, can resolve a nicer label and a link; this + /// is the fallback for one that does not. + /// + /// # Returns + /// + /// The display string, empty when the citation carries nothing. + pub fn display(&self) -> String { + if let Some(text) = &self.text { + return text.clone(); + } + match (&self.reference, &self.locator) { + (Some(k), Some(l)) => format!("{k} {l}"), + (Some(k), None) => k.clone(), + (None, Some(l)) => l.clone(), + (None, None) => String::new(), + } + } +} + +/// Writes a citation as a mapping, or as a bare string when that is all it holds. +impl Serialize for Citation { + fn serialize(&self, s: S) -> std::result::Result { + if let Some(text) = &self.text { + if self.reference.is_none() + && self.locator.is_none() + && self.path.is_none() + && self.url.is_none() + { + return s.serialize_str(text); + } + } + let mut map = s.serialize_map(None)?; + if let Some(v) = &self.reference { + map.serialize_entry("ref", v)?; + } + if let Some(v) = &self.locator { + map.serialize_entry("locator", v)?; + } + if let Some(v) = &self.path { + map.serialize_entry("path", v)?; + } + if let Some(v) = &self.url { + map.serialize_entry("url", v)?; + } + if let Some(v) = &self.text { + map.serialize_entry("text", v)?; + } + map.end() + } +} + +/// Accepts a citation written either as a mapping or as a bare string. +impl<'de> Deserialize<'de> for Citation { + fn deserialize>(d: D) -> std::result::Result { + /// The mapping form, with the field set kept in one place. + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Mapping { + #[serde(rename = "ref", default)] + reference: Option, + #[serde(default)] + locator: Option, + #[serde(default)] + path: Option, + #[serde(default)] + url: Option, + #[serde(default)] + text: Option, + } + + struct V; + impl<'a> Visitor<'a> for V { + type Value = Citation; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a citation mapping with a `ref`, or a plain citation string") + } + + fn visit_str(self, v: &str) -> std::result::Result { + Ok(Citation { + text: Some(v.to_string()), + ..Citation::default() + }) + } + + fn visit_map>( + self, + map: M, + ) -> std::result::Result { + let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(Citation { + reference: m.reference, + locator: m.locator, + path: m.path, + url: m.url, + text: m.text, + }) + } + } + d.deserialize_any(V) + } +} + +/// One line of a grading rubric for an open-response item. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RubricCriterion { + /// What earns the points, e.g. "states H = U + PV" or "compares to ~2.5 kJ/mol". + pub description: String, + /// Points for this line. Absent lets a grader decide; when present, the lines + /// are meant to sum to the item's point value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub points: Option, +} + +/// The worked solution to an item: what the answer is, why, and how it is graded. +/// +/// One place, versioned with the question, holds everything a student learns from +/// after the fact and everything a grader marks an open response against. For a +/// choice item the per-option [`Choice::explanation`] says why each option is right +/// or wrong; the solution adds the single worked line of reasoning a solutions +/// document leads with. For an [`Format::OpenResponse`] item the solution is the +/// whole answer, because there are no options to annotate. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Solution { + /// The model answer, in the authoring markup. For an open-response item this is + /// the response a full-credit student would write; for a choice item it is an + /// optional one-line statement of the key in words. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_answer: Option, + /// The worked reasoning a student can learn from: the derivation, the estimate, + /// the argument for the key over its neighbours. This is the body of the + /// solutions document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub explanation: Option, + /// How an open response is graded, one criterion per line. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rubric: Vec, + /// Responses a short constructed answer would be accepted as. Shown in the + /// solutions document as accepted answers, and the hook for automated grading + /// later. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub accepted: Vec, + /// Where to look again after missing this item, as citations into the course + /// reference registry. Resolved and linked by the exporters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub review: Vec, +} + +impl Solution { + /// Whether the solution carries anything worth rendering. + /// + /// Used to decide whether a solutions entry has a body to print, so an item + /// with an empty `solution:` block is treated as having none. + pub fn is_empty(&self) -> bool { + self.model_answer.is_none() + && self.explanation.is_none() + && self.rubric.is_empty() + && self.accepted.is_empty() + && self.review.is_empty() + } + + /// Total of the rubric line points, when every line carries one. + /// + /// # Returns + /// + /// The sum, or `None` if any line omits its points or the rubric is empty. + pub fn rubric_points(&self) -> Option { + if self.rubric.is_empty() { + return None; + } + self.rubric.iter().map(|c| c.points).sum::>() + } +} + /// A figure or data file reproduced with an item. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -473,6 +702,7 @@ impl Item { stimulus: None, stem: stem.to_string(), options, + solution: None, learning_objectives: Vec::new(), sources: Vec::new(), topics: Vec::new(), @@ -538,6 +768,14 @@ impl Item { self.key_indices().len() > 1 } + /// Whether the item presents selectable options, per its [`Format`]. + /// + /// `false` for an [`Format::OpenResponse`] item. Callers that would otherwise + /// index `options` or read a key should branch on this first. + pub fn has_options(&self) -> bool { + self.format.has_options() + } + /// The display title, falling back to a truncated stem. /// /// # Returns @@ -821,4 +1059,77 @@ options: IrtModel::ThreePl ); } + + #[test] + fn open_response_item_parses_without_options() { + let it = item( + r#" +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. +solution: + model_answer: >- + At constant pressure the P dV expansion work is folded into H = U + PV, so the + heat q_p equals the change in H. + rubric: + - { description: "states H = U + PV", points: 1 } + - { description: "identifies q_p with the enthalpy change", points: 1 } + review: + - { ref: kuriyan2012molecules, locator: "§6.4", path: "6/A/#4" } +"#, + ); + assert_eq!(it.format, Format::OpenResponse); + assert!(it.options.is_empty()); + assert!(!it.has_options()); + assert!(it.key_letters().is_empty()); + let sol = it.solution.as_ref().expect("has a solution"); + assert!(!sol.is_empty()); + assert_eq!(sol.rubric_points(), Some(2.0)); + assert_eq!(sol.review.len(), 1); + assert_eq!( + sol.review[0].reference.as_deref(), + Some("kuriyan2012molecules") + ); + } + + #[test] + fn a_solution_serializes_only_what_it_holds() { + let it = item( + r#" +id: q-demo-op-002 +status: draft +level: 2 +format: open_response +stem: State the first law. +solution: + model_answer: The total energy of an isolated system is constant. +"#, + ); + let yaml = serde_yaml_ng::to_string(&it).expect("serializes"); + // Open-response items carry no options key, and an empty rubric is omitted. + assert!(!yaml.contains("options:"), "no options key:\n{yaml}"); + assert!(!yaml.contains("rubric"), "empty rubric omitted:\n{yaml}"); + assert!(yaml.contains("model_answer:")); + } + + #[test] + fn a_citation_round_trips_as_string_or_mapping() { + // A bare string stays a bare string. + let bare: Citation = serde_yaml_ng::from_str("\"KKW §6.4 (course reserve)\"").unwrap(); + assert_eq!(bare.text.as_deref(), Some("KKW §6.4 (course reserve)")); + assert_eq!(bare.display(), "KKW §6.4 (course reserve)"); + let back = serde_yaml_ng::to_string(&bare).unwrap(); + assert_eq!(back.trim(), "KKW §6.4 (course reserve)"); + + // A mapping keeps its fields, and `ref` is the key's YAML spelling. + let mapped: Citation = + serde_yaml_ng::from_str("{ ref: kuriyan2012molecules, locator: \"§6.4\" }").unwrap(); + assert_eq!(mapped.reference.as_deref(), Some("kuriyan2012molecules")); + assert_eq!(mapped.display(), "kuriyan2012molecules §6.4"); + let back = serde_yaml_ng::to_string(&mapped).unwrap(); + assert!(back.contains("ref: kuriyan2012molecules")); + assert!(back.contains("locator:")); + } } diff --git a/src/model/taxonomy.rs b/src/model/taxonomy.rs index 8b717b2..b0946c4 100644 --- a/src/model/taxonomy.rs +++ b/src/model/taxonomy.rs @@ -423,17 +423,53 @@ pub enum Format { MultipleResponse, /// Two options, True and False. TrueFalse, + /// A free-text answer the student writes rather than selects. + /// + /// It carries no options and is not machine-scored. What a grader marks it + /// against, and what a solutions document shows, lives in the item's + /// [`crate::item::Solution`]: a model answer and, when the item is worth more + /// than a point, a rubric. This is the format for "explain", "derive", and + /// "estimate" prompts that a set of distractors would trivialize. + OpenResponse, } impl Format { + /// Every response format. + pub const ALL: [Format; 4] = [ + Format::SingleBestAnswer, + Format::MultipleResponse, + Format::TrueFalse, + Format::OpenResponse, + ]; + /// The QTI question type Canvas expects for this format. pub fn qti_type(self) -> &'static str { match self { Format::SingleBestAnswer => "multiple_choice_question", Format::MultipleResponse => "multiple_answers_question", Format::TrueFalse => "true_false_question", + Format::OpenResponse => "essay_question", } } + + /// The snake_case token used in YAML. + pub fn as_str(self) -> &'static str { + match self { + Format::SingleBestAnswer => "single_best_answer", + Format::MultipleResponse => "multiple_response", + Format::TrueFalse => "true_false", + Format::OpenResponse => "open_response", + } + } + + /// Whether items in this format present selectable options. + /// + /// `false` only for [`Format::OpenResponse`]. Validation, assembly, and the + /// exporters branch on this rather than on the variant, so the day a second + /// free-text format is added it inherits the no-options handling for free. + pub fn has_options(self) -> bool { + !matches!(self, Format::OpenResponse) + } } /// How strongly an item is expected to separate strong from weak students. @@ -633,4 +669,18 @@ mod tests { Status::InReview ); } + + #[test] + fn open_response_is_the_only_format_without_options() { + assert_eq!( + serde_json::from_str::("\"open_response\"").unwrap(), + Format::OpenResponse + ); + assert_eq!(Format::OpenResponse.as_str(), "open_response"); + assert_eq!(Format::OpenResponse.qti_type(), "essay_question"); + assert!(!Format::OpenResponse.has_options()); + for f in Format::ALL { + assert_eq!(f.has_options(), f != Format::OpenResponse); + } + } } diff --git a/src/util/markup.rs b/src/util/markup.rs index 6588c38..2a32663 100644 --- a/src/util/markup.rs +++ b/src/util/markup.rs @@ -113,6 +113,32 @@ pub fn to_plain(src: &str) -> String { out.trim().to_string() } +/// Converts authoring markup to Pandoc-flavoured Markdown, for a Quarto document. +/// +/// Subscripts and superscripts become Pandoc's `~x~` and `^x^`, the symbol table +/// renders as Unicode, and the bold, italic, and inline-code spans are already +/// Markdown, so they pass through unchanged. Paragraph breaks are kept. Nothing is +/// HTML-escaped, because the consumer is a Markdown renderer rather than a page. +/// +/// # Arguments +/// +/// * `src` - the authoring source. +/// +/// # Returns +/// +/// Pandoc Markdown, trimmed, with paragraph breaks preserved. +pub fn to_markdown(src: &str) -> String { + let symbolized = apply_symbols(src, false); + let mut out = wrap_bracket(&symbolized, "#sub[", "~", "~"); + out = wrap_bracket(&out, "#sup[", "^", "^"); + out.lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n") + .trim() + .to_string() +} + /// Passes authoring markup through for Typst. /// /// The markup is already a Typst subset, so this only normalizes whitespace and @@ -379,4 +405,21 @@ mod tests { assert_eq!(to_typst("a @ b"), "a \\@ b"); assert_eq!(to_typst("x < y"), "x \\< y"); } + + #[test] + fn markdown_uses_pandoc_scripts_and_unicode_symbols() { + assert_eq!(to_markdown("H#sub[2]O"), "H~2~O"); + assert_eq!(to_markdown("x#sup[2]"), "x^2^"); + assert_eq!( + to_markdown("K#sub[m] #sym.approx 5 mM"), + "K~m~ \u{2248} 5 mM" + ); + // Bold, italic, and code are already Markdown. + assert_eq!( + to_markdown("**bold** and *em* and `code`"), + "**bold** and *em* and `code`" + ); + // Paragraph breaks survive. + assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo"); + } }