feat: improve worksheet
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
preview
|
||||
scratch
|
||||
|
||||
/dist/
|
||||
/THIRD-PARTY-LICENSES.txt
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+10
-4
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- clarity
|
||||
// --- clarity
|
||||
let stem = it.stem.trim();
|
||||
let stem_lower = stem.to_lowercase();
|
||||
let words: Vec<&str> = stem.split_whitespace().collect();
|
||||
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- cueing
|
||||
// --- cueing
|
||||
let keys: Vec<&crate::item::Choice> = 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<Find
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- completeness
|
||||
// --- completeness
|
||||
// These are only worth insisting on once an item is meant to be used.
|
||||
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
|
||||
if is_ready {
|
||||
@@ -749,7 +749,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- evidence
|
||||
// --- evidence
|
||||
if !it.calibration_is_current() {
|
||||
push(
|
||||
Rule::StaleCalibration,
|
||||
@@ -868,6 +868,12 @@ fn lint_option_counts(catalog: &Catalog) -> Vec<Finding> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = Vec::new();
|
||||
let mut per_bank: BTreeMap<String, usize> = 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<String> = 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<String> = Vec::new();
|
||||
for (level, want) in &blueprint.bonus_counts {
|
||||
if *want == 0 {
|
||||
|
||||
+23
@@ -412,6 +412,29 @@ pub(crate) enum ExportCommand {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// 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 <file>.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<String>,
|
||||
/// Output directory; defaults to build/.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Do not leave written-answer space after open-response questions.
|
||||
#[arg(long)]
|
||||
no_answer_space: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
|
||||
@@ -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<Outcome> {
|
||||
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 <file>.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<Vec<practice::Variant>> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 {
|
||||
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_objectives.is_empty() {
|
||||
return;
|
||||
}
|
||||
let texts: Vec<String> = 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<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_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\""));
|
||||
}
|
||||
}
|
||||
+107
-4
@@ -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 `<other/>` 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!(
|
||||
"<div>{}</div>",
|
||||
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!("<div>{}</div>", markup::to_html(text)))),
|
||||
));
|
||||
}
|
||||
|
||||
node
|
||||
}
|
||||
|
||||
/// A `<material><mattext texttype="text/html">` pair.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
+11
-11
@@ -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(
|
||||
|
||||
@@ -662,11 +662,7 @@ fn calibration(item: &Item) -> Option<BTreeMap<String, f64>> {
|
||||
|
||||
/// 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.
|
||||
|
||||
+1
-1
@@ -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};
|
||||
|
||||
+96
-11
@@ -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(
|
||||
|
||||
+312
-1
@@ -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<Choice>,
|
||||
|
||||
/// 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<Solution>,
|
||||
|
||||
/// Objectives this item measures, as ids into the course registry.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
@@ -239,6 +260,214 @@ pub struct Source {
|
||||
pub recording_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`.
|
||||
pub locator: Option<String>,
|
||||
/// Appended to the reference's `base_url` to reach this location.
|
||||
pub path: Option<String>,
|
||||
/// A full URL, when the location is not under the reference's `base_url`.
|
||||
pub url: Option<String>,
|
||||
/// A citation written as a bare string, held unparsed.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
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<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
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: Deserializer<'de>>(d: D) -> std::result::Result<Citation, D::Error> {
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
locator: Option<String>,
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
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<E: de::Error>(self, v: &str) -> std::result::Result<Citation, E> {
|
||||
Ok(Citation {
|
||||
text: Some(v.to_string()),
|
||||
..Citation::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'a>>(
|
||||
self,
|
||||
map: M,
|
||||
) -> std::result::Result<Citation, M::Error> {
|
||||
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<f64>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// How an open response is graded, one criterion per line.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rubric: Vec<RubricCriterion>,
|
||||
/// 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<String>,
|
||||
/// 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<Citation>,
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
if self.rubric.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.rubric.iter().map(|c| c.points).sum::<Option<f64>>()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Format>("\"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user