5 Commits
31 changed files with 5194 additions and 107 deletions
+1
View File
@@ -1,4 +1,5 @@
preview
scratch
/dist/
/THIRD-PARTY-LICENSES.txt
+8 -7
View File
@@ -17,10 +17,6 @@ path = "src/main.rs"
name = "coursebank"
path = "src/lib.rs"
[features]
default = ["parquet"]
parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"]
[dependencies]
clap = { version = "4", features = ["derive"] }
csv = "1"
@@ -29,9 +25,14 @@ serde_json = "1"
serde_yaml_ng = "0.10"
thiserror = "2"
arrow-array = { version = "55", optional = true }
arrow-schema = { version = "55", optional = true }
parquet = { version = "55", optional = true }
arrow-array = { version = "55"}
arrow-schema = { version = "55"}
parquet = { version = "55"}
aes-gcm = { version = "0.10"}
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"]}
sha2 = { version = "0.10"}
base64 = { version = "0.22"}
getrandom = { version = "0.2"}
[profile.release]
opt-level = 3
+192
View File
@@ -0,0 +1,192 @@
# An assignment on the web
This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password.
It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both.
The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`.
The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser.
## Assemble the homework
Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas:
```console
$ coursebank assemble a1.1 \
--title "Homework 1" \
--kind homework --platform other \
--levels 2=1,3=1 --lectures L1.1 --seed 20260210
```
What lands in `assessments/a1.1.yaml` is a record of the draw.
The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable.
A single unshuffled form is fine for homework.
To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed.
## Export for the web
```console
$ coursebank export site a1.1 --out build/a1.1 --assets build/static
password for a1.1: k7m4-9p2q-r8tx-3wn6
wrote build/a1.1/_questions.qmd
wrote build/a1.1/a1.1-solutions.json
wrote build/static/questions.css
wrote build/static/solutions.js
Include in the page with: {{< include _questions.qmd >}}
```
That is four files and a password.
`_questions.qmd` is the partial you include from the page.
`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site.
The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward.
The password is printed once and written nowhere.
Record it now.
It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back.
## What the partial holds
The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land:
```text
::: {.solutions-gate data-bundle="a1.1-solutions.json"}
:::
::: {.q #q-enthalpy-qp-001}
:::: {.q-head}
[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}
::::
:::: {.q-stem}
A reaction is run in an open flask, so the system stays at the constant
pressure of the room. The heat the reaction exchanges with its surroundings
equals the change in which quantity?
::::
:::: {.q-choices}
1. Enthalpy, $\Delta H$
2. Internal energy, $\Delta U$
3. Gibbs free energy, $\Delta G$
4. Entropy, $\Delta S$
::::
:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"}
::::
:::
```
The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position.
There is nothing in this file to leak: no `correct` flag, no solution text, no rationale.
The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up.
Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page.
## What the bundle holds
Every solution is rendered to HTML, then encrypted:
```json
{
"v": 1,
"page": "a1.1",
"kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." },
"cipher": "AES-GCM",
"items": {
"q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" },
"q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" }
}
}
```
The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt.
Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext.
The plaintext HTML never leaves your machine.
Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest.
## Wire it into the site
Install the two assets once in `_quarto.yml`:
```yaml
format:
html:
css:
- static/questions.css
include-after-body:
- static/solutions.js
```
Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page:
```markdown
---
title: "Homework 1"
---
{{< include _questions.qmd >}}
```
The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough.
If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render.
Render with `quarto render`.
A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset.
## Hand out the password, and rotate it
Give the password through a channel students already have, such as the course LMS, rather than the site itself.
Be clear-eyed about what the lock does.
It keeps solutions off a public page until someone has the password.
It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack.
Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record.
Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle:
```console
$ coursebank export site a1.1 --out build/a1.1
password for a1.1: 2h9k-w4rq-8mnp-x6tv
...
```
To set a password yourself instead of generating one, pass `--password`.
Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated.
## Doing this from Rust
The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature.
The example below needs `--features site` to build, so it is not run as a doctest:
```rust,ignore
use std::path::Path;
use coursebank::assessment::{AssessmentFile, Form};
use coursebank::site::{self, Options};
use coursebank::Catalog;
fn main() -> coursebank::Result<()> {
let catalog = Catalog::load(Path::new("."))?;
let path = catalog.layout.assessments().join("a1.1.yaml");
let record = AssessmentFile::load(&path)?;
// An unshuffled form A; declare a form with a seed to shuffle.
let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false };
// `password: None` generates a fresh one; pass `Some(_)` to set your own.
let rendered = site::render(&catalog, &record, Options { form, password: None })?;
std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?;
std::fs::write(
format!("build/a1.1/{}-solutions.json", rendered.page),
&rendered.solutions_json,
)?;
// The password is the one thing not on disk; print it now.
println!("password for {}: {}", rendered.page, rendered.password);
Ok(())
}
```
`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`.
+67
View File
@@ -26,8 +26,75 @@ That gives you:
`build/` and `reports/` are in the generated `.gitignore`.
The other four are the repository's content and belong in review.
If the directory already has a `.gitignore`, `init` keeps it and inserts only the patterns it was missing at the top, so running this inside an existing repository costs you nothing.
Add `--with-examples` if you want a filled-in bank to read rather than an empty directory to stare at.
## Declare your texts once
Every work the course cites goes in `references`, keyed by the citation key you would use in a `.bib` file.
```yaml
references:
kuriyan2013molecules:
label: KKW
kind: book
role: required
title: 'The molecules of life: Physical and chemical principles'
authors: ['Kuriyan, John', 'Konforti, Boyana', 'Wemmer, David']
year: 2013
publisher: W. W. Norton & Company
base_url: https://library.scient.ing/kuriyan2013molecules/
note: On reserve at the Bevier Engineering Library.
```
`label` is the short form a reading list shows, and it has to name one work, because reports print it instead of the key.
`base_url` is what a reading's `path` is joined to, so the key appears once in the file rather than once per reading.
## Point readings at objectives
A reading names a location inside a reference and lists the objectives it serves.
```yaml
lectures:
L1.1:
title: Enthalpy
readings:
- ref: kuriyan2013molecules
locator: '§1.3'
path: '1/A/#3'
objectives: [lo-water-attenuation, lo-coulomb-estimate]
summary: >-
Ionic interactions: favorable in vacuum, attenuated ~80-fold by water.
focus: >-
The two magnitudes and the factor of 80.
skip: >-
Skip the unit-conversion derivation.
```
The three prose fields answer three different questions, and each has a different reader.
`summary` says what the section contains, `focus` says what to take from it, and `skip` says what to ignore.
A student report quotes `focus` at somebody who missed the objective; a lecture page prints all three.
The mapping lives on the reading rather than on the objective because objectives outlive editions.
When a textbook renumbers its sections, one block of `readings` changes and `learning_objectives` does not.
Going the other way is a scan: `coursebank lecture coverage` lists the readings behind each objective and flags the ones with none.
Set `order` on each objective if you want a lecture page to number them in teaching order.
The registry is a map, so declaration order is lost on load, and sorting by id would put `lo-enthalpy` ahead of `lo-first-law`.
A reading written as a plain string, which is what this field held before, still loads and is written back out unchanged.
## Generate the reading list
```console
$ coursebank lecture readings L1.1 --out lectures/l1_1-readings.qmd
wrote lectures/l1_1-readings.qmd
```
Objective numbers in the generated page (`_(LO 4, 7)_`) are positional, so they are computed at render time rather than written down.
Insert an objective and everything after it renumbers on the next build.
## Point your editor at the schemas
The schemas are the difference between authoring items and looking up field names.
+4 -4
View File
@@ -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 {
+249
View File
@@ -0,0 +1,249 @@
/* ============================================================================
questions.css — worksheet items + gated solutions
Sits beside editor-notes.css: same left-border-accent language, same
small-caps auto-headers, same .quarto-dark dark-mode hook.
Worksheet content is authored as Quarto MARKDOWN inside fenced divs, so
inline math is $ … $ and paragraphs/lists render normally. Choice letters
(A, B, C, D) are drawn by a CSS counter, so a choice is just a list item.
Semantic color coding (information, not decoration):
indigo = a solution / answer region
green = the correct choice / accepted answer
red = a named misconception
==========================================================================*/
:root {
--q-ink: #1f2328;
--q-muted: #5a5a5a;
--q-line: #e4e4e4;
--q-card-bg: #ffffff;
--sol-accent: #4b4f9a; /* indigo: "here be answers" */
--sol-bg: #f5f5fb;
--sol-rule: #dedcef;
--ok-ink: #2f6249; /* correct / accepted (matches editorial green) */
--ok-bg: #e7f2ec;
--mis-ink: #a13d2e; /* misconception (matches editorial red) */
}
/* ---- Question card ---- */
.q {
border: 1px solid var(--q-line);
border-radius: 6px;
background: var(--q-card-bg);
padding: 1.1rem 1.3rem 1.25rem;
margin: 1.6rem 0;
}
/* Header. Authored as [Question 1]{.q-num} [..]{.q-kind} [..]{.q-points}; */
.q-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.7rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--q-line);
}
.q-head > p { display: contents; margin: 0; }
.q-num { font-weight: 700; letter-spacing: 0.01em; }
.q-kind {
font-variant: small-caps; letter-spacing: 0.06em;
font-size: 0.78rem; color: var(--q-muted);
}
.q-points {
margin-left: auto; font-size: 0.8rem; color: var(--q-muted);
white-space: nowrap;
}
.q-stem { margin: 0 0 0.9rem; }
.q-stem > p:first-child { margin-top: 0; }
.q-stem > p:last-child { margin-bottom: 0; }
/* ---- Multiple choice (a plain ordered list; letters via counter) ---- */
.q-choices > ol {
list-style: none; margin: 0; padding: 0;
display: grid; gap: 0.5rem;
counter-reset: choice;
}
.q-choices > ol > li {
position: relative;
padding: 0.55rem 0.7rem 0.55rem 3rem; /* room for the badge on the left */
border: 1px solid var(--q-line);
border-radius: 5px;
counter-increment: choice;
}
.q-choices > ol > li::before {
content: counter(choice, upper-alpha); /* A, B, C, D … */
position: absolute;
left: 0.55rem; top: 0.5rem;
display: grid; place-items: center;
width: 1.9rem; height: 1.9rem;
border: 1px solid var(--sol-accent);
border-radius: 50%;
font-weight: 700; font-size: 0.9rem;
color: var(--sol-accent);
}
/* ---- Free-response writing space (prints with room to write) ----- */
.q-response {
min-height: 6.5rem;
border: 1px dashed var(--q-line);
border-radius: 5px;
background:
repeating-linear-gradient(
to bottom, transparent, transparent 1.55rem,
var(--q-line) 1.55rem, var(--q-line) calc(1.55rem + 1px));
background-position: 0 0.9rem;
}
.q-response::before {
content: "Your answer";
display: block;
font-variant: small-caps; letter-spacing: 0.06em;
font-size: 0.72rem; color: var(--q-muted);
padding: 0.3rem 0.6rem 0;
}
/* ---- Solution slot (filled by solutions.js on unlock) ---- */
.qsol {
border-left: 3px solid var(--sol-accent);
border-radius: 0 4px 4px 0;
background: var(--sol-bg);
padding: 0.9rem 1.15rem;
margin-top: 1rem;
font-size: 0.95rem; line-height: 1.55;
}
.qsol::before {
content: "Solution";
display: block;
font-variant: small-caps; letter-spacing: 0.06em; font-weight: 600;
color: var(--sol-accent);
padding-bottom: 0.35rem; margin-bottom: 0.6rem;
border-bottom: 1px solid var(--sol-rule);
}
.qsol > p:first-of-type { margin-top: 0; }
.qsol > *:last-child { margin-bottom: 0; }
@keyframes sol-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } }
.qsol.is-unlocked { animation: sol-in 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { .qsol.is-unlocked { animation: none; } }
/* pieces inside a solution */
.sol-answer { font-size: 1.02rem; }
.sol-model {
border-left: 2px solid var(--ok-ink);
background: var(--ok-bg);
padding: 0.55rem 0.8rem; border-radius: 0 4px 4px 0; margin: 0.7rem 0;
}
.sol-model > p:first-child { margin-top: 0; }
.sol-model > p:last-child { margin-bottom: 0; }
.sol-explain { margin: 0.7rem 0; }
.sol-feedback-title {
font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600;
color: var(--q-muted); margin: 0.9rem 0 0.4rem;
}
/* per-distractor feedback: badge in col 1, BOTH text spans in col 2 ---- */
.sol-feedback { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
.sol-feedback > li {
display: grid;
grid-template-columns: 1.9rem 1fr; /* badge | body */
gap: 0.6rem;
align-items: start;
}
.sol-feedback .opt {
display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
border-radius: 50%; font-weight: 700; font-size: 0.8rem;
color: var(--mis-ink); border: 1px solid var(--mis-ink);
}
.sol-feedback .opt-body { /* the single col-2 cell; fills 1fr */
display: grid; gap: 0.25rem;
}
.sol-feedback .opt-mis { color: var(--mis-ink); font-style: italic; }
.sol-feedback .opt-why { color: var(--q-ink); }
/* rubric table */
.sol-rubric { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 0.92rem; }
.sol-rubric caption {
text-align: left; font-variant: small-caps; letter-spacing: 0.05em;
font-weight: 600; color: var(--q-muted); padding-bottom: 0.35rem;
}
.sol-rubric th, .sol-rubric td {
border-top: 1px solid var(--sol-rule); padding: 0.4rem 0.55rem; text-align: left;
vertical-align: top;
}
.sol-rubric th:first-child, .sol-rubric td:first-child {
width: 2.5rem; text-align: center; font-weight: 700; color: var(--ok-ink);
}
.sol-accepted { margin: 0.7rem 0; }
.sol-ref { margin-top: 0.7rem; font-size: 0.85rem; color: var(--q-muted); }
/* badges */
.badge {
display: inline-block; font-variant: small-caps; letter-spacing: 0.05em;
font-size: 0.72rem; font-weight: 700; padding: 0.05em 0.5em;
border-radius: 999px; margin-right: 0.35em;
background: var(--sol-rule); color: var(--sol-accent);
}
.badge-correct { background: var(--ok-bg); color: var(--ok-ink); }
/* ---- The password gate --- */
.solutions-gate { margin: 1.4rem 0; }
.gate-inner {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
padding: 0.75rem 1rem;
border: 1px solid var(--sol-rule); border-left: 3px solid var(--sol-accent);
border-radius: 0 5px 5px 0; background: var(--sol-bg);
}
.gate-lock {
width: 0.8rem; height: 0.7rem; border: 2px solid var(--sol-accent);
border-radius: 2px; position: relative; flex: none;
}
.gate-lock::before {
content: ""; position: absolute; left: 50%; top: -0.42rem; transform: translateX(-50%);
width: 0.5rem; height: 0.42rem; border: 2px solid var(--sol-accent);
border-bottom: none; border-radius: 4px 4px 0 0;
}
.gate-lock.is-open::before { left: 20%; }
.gate-label { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; color: var(--sol-accent); }
.gate-input {
flex: 1 1 12rem; min-width: 9rem;
padding: 0.4rem 0.6rem; border: 1px solid var(--sol-rule);
border-radius: 4px; background: var(--q-card-bg); color: var(--q-ink);
}
.gate-btn {
padding: 0.42rem 1rem; border: 1px solid var(--sol-accent); border-radius: 4px;
background: var(--sol-accent); color: #fff; font-weight: 600; cursor: pointer;
}
.gate-btn:hover { filter: brightness(1.08); }
.gate-btn:disabled { opacity: 0.6; cursor: progress; }
.gate-btn-ghost { background: transparent; color: var(--sol-accent); }
.gate-status { flex-basis: 100%; margin: 0; font-size: 0.85rem; color: var(--q-muted); }
.solutions-gate.is-error .gate-input { border-color: var(--mis-ink); }
.solutions-gate.is-error .gate-status { color: var(--mis-ink); }
.gate-input:focus-visible, .gate-btn:focus-visible { outline: 2px solid var(--sol-accent); outline-offset: 2px; }
@media print {
.solutions-gate { display: none; }
.qsol[hidden] { display: none; }
.q { break-inside: avoid; border-color: #bbb; }
}
/* ============================ Dark mode ================================= */
.quarto-dark {
--q-ink: #dfe2e7;
--q-muted: #a7adb6;
--q-line: #333a44;
--q-card-bg: #1b1f26;
--sol-accent: #9aa0e6;
--sol-bg: #20222e;
--sol-rule: #343755;
--ok-ink: #9dd3b4;
--ok-bg: #1b241f;
--mis-ink: #e0a498;
}
.quarto-dark .gate-btn { color: #14161c; }
+160
View File
@@ -0,0 +1,160 @@
/* ============================================================================
* solutions.js — per-page, password-gated solutions for the course site.
*
* How a page opts in:
* 1. Put one gate element somewhere on the page:
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
* `data-bundle` is resolved relative to the page URL, so each page points
* at its own bundle and therefore has its own password. Nothing else is
* shared between pages.
* 2. For every gated item, leave an empty, hidden slot where the solution
* should appear:
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
* The id in data-solution-for must match a key in the bundle's `items`.
*
* What happens on unlock:
* The typed password is run through PBKDF2 (same params as the bundle's kdf
* block) to derive an AES-256-GCM key. The first item is decrypted as a
* probe: because GCM authenticates, a wrong password throws, and we report
* "wrong password" without revealing anything. On success every slot is
* filled, un-hidden, and MathJax re-typesets the injected math.
*
* The ciphertext ships in the page, so this stops a student
* from reading answers in "View source" or the Network tab, and a wrong
* password reveals nothing. Anyone who has the password can decrypt, and the
* bundle can be brute-forced offline against a weak password. Use a real,
* non-guessable per-page password, and rotate it if a key deadline has passed.
* ==========================================================================*/
(() => {
"use strict";
const b64ToBytes = (s) =>
Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
async function deriveKey(password, kdf) {
const base = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: b64ToBytes(kdf.salt),
iterations: kdf.iterations, hash: kdf.hash },
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
}
async function decryptItem(key, item) {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct));
return new TextDecoder().decode(pt);
}
function typeset(nodes) {
if (window.MathJax && typeof window.MathJax.typesetPromise === "function") {
window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ });
}
}
function buildGate(gate) {
gate.classList.add("is-locked");
gate.innerHTML = `
<div class="gate-inner">
<span class="gate-lock" aria-hidden="true"></span>
<label class="gate-label" for="gate-pw">Solutions are locked</label>
<input class="gate-input" id="gate-pw" type="password"
autocomplete="off" spellcheck="false"
placeholder="Enter the page password" />
<button class="gate-btn" type="button">Unlock</button>
<p class="gate-status" role="status" aria-live="polite"></p>
</div>`;
return {
input: gate.querySelector(".gate-input"),
button: gate.querySelector(".gate-btn"),
status: gate.querySelector(".gate-status"),
};
}
async function unlock(gate, ui) {
const url = gate.getAttribute("data-bundle");
const pw = ui.input.value;
if (!pw) { ui.input.focus(); return; }
gate.classList.remove("is-error");
ui.button.disabled = true;
ui.status.textContent = "Checking\u2026";
let bundle;
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`bundle ${res.status}`);
bundle = await res.json();
} catch (e) {
ui.button.disabled = false;
ui.status.textContent = "Could not load the solutions file for this page.";
return;
}
let key;
try {
key = await deriveKey(pw, bundle.kdf);
// Probe with the first item so a wrong password fails before we touch DOM.
const firstId = Object.keys(bundle.items)[0];
await decryptItem(key, bundle.items[firstId]);
} catch (e) {
gate.classList.add("is-error");
ui.button.disabled = false;
ui.status.textContent = "That password didn\u2019t work. Try again.";
ui.input.select();
return;
}
const filled = [];
for (const slot of document.querySelectorAll("[data-solution-for]")) {
const id = slot.getAttribute("data-solution-for");
const item = bundle.items[id];
if (!item) continue;
try {
slot.innerHTML = await decryptItem(key, item);
slot.hidden = false;
slot.classList.add("is-unlocked");
filled.push(slot);
} catch (e) { /* skip an item that fails; others still unlock */ }
}
typeset(filled);
gate.classList.remove("is-locked");
gate.classList.add("is-unlocked");
gate.innerHTML = `
<div class="gate-inner">
<span class="gate-lock is-open" aria-hidden="true"></span>
<span class="gate-label">Solutions unlocked</span>
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
</div>`;
gate.querySelector(".gate-btn").addEventListener("click", () => {
for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); }
buildAndWire(gate); // relock the UI; content stays in memory only
});
}
function buildAndWire(gate) {
const ui = buildGate(gate);
const go = () => unlock(gate, ui);
ui.button.addEventListener("click", go);
ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
}
function init() {
const gate = document.querySelector(".solutions-gate[data-bundle]");
if (!gate) return;
if (!window.crypto || !crypto.subtle) {
gate.textContent =
"This browser can\u2019t decrypt solutions (no Web Crypto over http/file).";
return;
}
buildAndWire(gate);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+184 -5
View File
@@ -291,7 +291,12 @@ fn lecture_schema() -> Value {
"date": date("Date delivered."),
"unit": { "type": "string", "description": "Unit id." },
"slides_url": { "type": "string" },
"readings": string_array("Readings assigned with this lecture.")
"readings": {
"type": "array",
"description": "Readings assigned with this lecture, in the order you assign \
them. A plain string is the pre-schema form and still loads.",
"items": reading_schema()
}
}
})
}
@@ -306,6 +311,13 @@ fn objective_schema() -> Value {
"text": text("The objective as a student would read it. Start with a verb."),
"unit": { "type": "string" },
"lectures": string_array("Lecture ids that cover this."),
"order": {
"type": "integer",
"minimum": 1,
"description": "Position in teaching order, low first. A lecture page numbers \
objectives by this; without it they sort by id, which puts \
an objective before its own prerequisite."
},
"level_ceiling": level(),
"prerequisites": string_array(
"Objective ids that must come first. Cycles are rejected."
@@ -320,6 +332,93 @@ fn objective_schema() -> Value {
})
}
/// The schema for one cited work.
fn reference_schema() -> Value {
json!({
"type": "object",
"required": ["title"],
"additionalProperties": false,
"properties": {
"label": text("Short form a reading list shows, such as KKW. One work per label."),
"kind": {
"type": "string",
"enum": strings(&[
"book", "chapter", "article", "preprint", "thesis",
"website", "software", "dataset", "video", "other"
]),
"description": "Kind of work, following BibTeX entry types."
},
"role": {
"type": "string",
"enum": strings(&["required", "supplemental"]),
"description": "required for a course text; supplemental for background."
},
"title": text("Full title."),
"authors": string_array("Authors as `Family, Given`, in printed order."),
"year": { "type": "integer", "description": "Year of publication." },
"edition": { "type": "string", "description": "Edition as printed: 7th." },
"publisher": { "type": "string" },
"container": { "type": "string", "description": "Journal, edited volume, or series." },
"volume": { "type": "string" },
"issue": { "type": "string" },
"pages": { "type": "string", "description": "Pages of the work, not of a reading." },
"doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." },
"isbn": { "type": "string" },
"url": { "type": "string", "description": "Canonical URL for the whole work." },
"base_url": {
"type": "string",
"description": "Prefix a reading's `path` is joined to, so the citation key \
appears once instead of once per reading."
},
"note": { "type": "string", "description": "Access notes: reserve shelf, license." }
}
})
}
/// The schema for one reading: a location inside a reference, and what it is for.
fn reading_schema() -> Value {
json!({
"oneOf": [
{ "type": "string", "description": "The pre-schema form: a citation, unparsed." },
reading_mapping_schema()
]
})
}
/// The mapping form of a reading.
fn reading_mapping_schema() -> Value {
json!({
"type": "object",
"required": ["ref"],
"additionalProperties": false,
"properties": {
"ref": text("Citation key into `references`."),
"locator": text("Where inside the work: §6.1, pp. 212-219, ch. 3."),
"path": {
"type": "string",
"description": "Joined to the reference's base_url to reach this location."
},
"url": {
"type": "string",
"description": "Full URL, when base_url does not cover the location."
},
"role": {
"type": "string",
"enum": strings(&["assigned", "supplemental"]),
"description": "supplemental means offered but not separately assessed."
},
"objectives": string_array(
"Objective ids this reading serves. A student who misses one of these is \
pointed here, so the list is what makes study guidance specific."
),
"summary": text("What the section contains."),
"focus": text("What to take from it. This is the sentence a student report quotes."),
"skip": text("What to gloss, and why it is out of scope."),
"text": text("A pre-schema citation string, held unparsed.")
}
})
}
/// The schema for one shared stimulus.
fn stimulus_schema() -> Value {
json!({
@@ -373,6 +472,12 @@ fn course_schema() -> Value {
"type": "object",
"description": "Shared passages, figures, or data that several items refer to.",
"additionalProperties": stimulus_schema()
},
"references": {
"type": "object",
"description": "Works the course cites, by citation key. Readings point in \
here, so an edition change is one edit.",
"additionalProperties": reference_schema()
}
}
})
@@ -466,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!({
@@ -633,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 },
@@ -662,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."
@@ -702,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
View File
@@ -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);
}
+4 -4
View File
@@ -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 {
+115
View File
@@ -23,6 +23,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use coursebank::assessment::{Kind as AssessmentKind, Platform};
use coursebank::catalog::Severity;
use coursebank::item::IrtModel;
use coursebank::lecture::Style as PageStyle;
use coursebank::store;
/// Manage course item banks, assessments, and the analysis that comes back.
@@ -55,6 +56,9 @@ pub(crate) enum Command {
Lint(LintArgs),
/// Summarize the item pool and objective coverage.
Catalog(CatalogArgs),
/// Render a lecture's reading list, or check what backs each objective.
#[command(subcommand)]
Lecture(LectureCommand),
/// Work with item banks.
#[command(subcommand)]
Bank(BankCommand),
@@ -123,6 +127,64 @@ pub(crate) struct LintArgs {
}
/// CLI mirror of [`coursebank::catalog::Severity`].
#[derive(Debug, Subcommand)]
pub(crate) enum LectureCommand {
/// Write the readings block for one lecture.
///
/// The course file is the source of truth for what a lecture assigns and why,
/// so the list on the website is generated from it. Objective numbers are
/// positional and are resolved here rather than authored.
Readings {
/// Lecture id, e.g. L1.1.
id: String,
/// Which flavour of Markdown to write.
#[arg(long, value_enum, default_value = "quarto")]
style: StyleArg,
/// Output path; prints to stdout when omitted.
#[arg(long)]
out: Option<PathBuf>,
},
/// Write the objectives block for one lecture, grouped by level.
///
/// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
/// `readings`, so generating one and hand-writing the other is what this
/// exists to prevent.
Objectives {
/// Lecture id, e.g. L1.1.
id: String,
/// Which flavour of Markdown to write.
#[arg(long, value_enum, default_value = "quarto")]
style: StyleArg,
/// Output path; prints to stdout when omitted.
#[arg(long)]
out: Option<PathBuf>,
},
/// Show the readings behind each objective, and which objectives have none.
Coverage {
/// Only this lecture's objectives.
#[arg(long)]
lecture: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum StyleArg {
/// Pandoc definition lists, as a Quarto lecture page wants them.
Quarto,
/// Plain Markdown bullets.
Plain,
}
impl StyleArg {
/// Converts the CLI value into the library's [`PageStyle`].
pub(crate) fn as_style(self) -> PageStyle {
match self {
StyleArg::Quarto => PageStyle::Quarto,
StyleArg::Plain => PageStyle::Plain,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum SeverityArg {
Low,
@@ -309,6 +371,11 @@ pub(crate) enum ExportCommand {
/// Leave per-option feedback out of the package.
#[arg(long)]
no_feedback: bool,
/// Canvas attempt limit; -1 for unlimited. Overrides the assessment's
/// `attempts` field. More than one attempt also makes per-option feedback
/// show the hint rather than the misconception.
#[arg(long)]
attempts: Option<i64>,
},
/// Render a printable exam, answer key, and answer sheet.
///
@@ -350,6 +417,54 @@ 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,
},
/// Write a Quarto questions partial and an encrypted, password-gated
/// solutions bundle for the course website.
///
/// Needs the `site` feature (`cargo build --features site`). Writes
/// `_questions.qmd` and `<id>-solutions.json` into the output directory, and
/// prints a fresh password that the files do not store.
Site {
/// Assessment id.
id: String,
/// Which form's option order to print.
#[arg(long, default_value = "A")]
form: String,
/// Output directory; defaults to build/. Point it at the page's own
/// directory so the browser fetches the bundle beside the page.
#[arg(long)]
out: Option<PathBuf>,
/// Encrypt with this password instead of a generated one. Use only to
/// re-encrypt a page with a known password.
#[arg(long)]
password: Option<String>,
/// Also write questions.css and solutions.js into this directory, e.g.
/// the site's static assets folder. They install once, not per page.
#[arg(long, value_name = "DIR")]
assets: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
+4
View File
@@ -10,6 +10,8 @@
//!
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
//! `lint`, `catalog`.
//! - [`lectures`] — render a lecture's reading list and check what backs each
//! objective: `lecture`.
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
//! `assemble`, `usage`.
//! - [`export`] — turn an assessment into deliverables: `export`, `template`.
@@ -22,6 +24,7 @@
pub(crate) mod analysis;
pub(crate) mod banks;
pub(crate) mod export;
pub(crate) mod lectures;
pub(crate) mod project;
use coursebank::error::Result;
@@ -56,6 +59,7 @@ pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
Command::Validate => project::validate(cli),
Command::Lint(args) => project::lint(cli, args),
Command::Catalog(args) => project::catalog(cli, args),
Command::Lecture(sub) => lectures::lecture(cli, sub),
Command::Bank(sub) => banks::bank(cli, sub),
Command::Assessment(sub) => banks::assessment(cli, sub),
Command::Assemble(args) => banks::assemble(cli, args),
+107
View File
@@ -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;
@@ -31,6 +32,7 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
form,
out,
no_feedback,
attempts: _,
} => {
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
@@ -168,9 +170,114 @@ 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)
}
ExportCommand::Site {
id,
form,
out,
password,
assets,
} => {
{
use coursebank::site;
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
let dir = out.clone().unwrap_or(build);
std::fs::create_dir_all(&dir)?;
let rendered = site::render(
&catalog,
&record,
site::Options {
form,
password: password.clone(),
},
)?;
let qmd = dir.join("_questions.qmd");
yaml::write_text(&qmd, &rendered.questions_qmd)?;
let json = dir.join(format!("{}-solutions.json", rendered.page));
yaml::write_text(&json, &rendered.solutions_json)?;
if let Some(asset_dir) = assets {
std::fs::create_dir_all(asset_dir)?;
for (name, body) in site::assets() {
let path = asset_dir.join(name);
yaml::write_text(&path, body)?;
if !cli.quiet {
println!("wrote {}", path.display());
}
}
}
// The password cannot be recovered from the files; always print it.
println!("password for {}: {}", rendered.page, rendered.password);
if !cli.quiet {
println!("wrote {}", qmd.display());
println!("wrote {}", json.display());
println!("\nInclude in the page with: {{{{< include _questions.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
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering lecture pages, and checking what backs each objective.
//!
//! Both handlers here read the course file and nothing else, so neither needs a
//! bank or a single response. That is deliberate: a reading list is useful in week
//! one, before any item exists.
use coursebank::course::CourseFile;
use coursebank::error::Result;
use coursebank::lecture::{objectives_markdown, readings_markdown};
use coursebank::yaml;
use crate::cli::{Cli, LectureCommand};
use crate::commands::Outcome;
/// `lecture`: render a reading list, or report reading coverage.
pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<Outcome> {
let course = CourseFile::load_dir(&cli.course)?;
match sub {
LectureCommand::Readings { id, style, out } => emit(
readings_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Objectives { id, style, out } => emit(
objectives_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
}
}
/// Writes rendered Markdown to a file, or to stdout when no path was given.
fn emit(markdown: String, out: Option<&std::path::Path>) -> Result<Outcome> {
match out {
Some(path) => {
yaml::write_text(path, &markdown)?;
println!("wrote {}", path.display());
}
None => print!("{markdown}"),
}
Ok(Outcome::Ok)
}
/// Prints the readings behind each objective.
///
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
/// that is the case where a student report can name what was missed but not where
/// to go and read about it.
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
let ids: Vec<String> = match lecture {
Some(l) => course
.lecture_objectives(l)
.into_iter()
.map(str::to_string)
.collect(),
None => course.objectives_in_order(),
};
for id in &ids {
let readings = course.readings_for_objective(id);
println!("{id}");
if readings.is_empty() {
println!(" (no reading)");
continue;
}
for (lecture_id, reading) in readings {
let Some(key) = reading.reference.as_deref() else {
continue;
};
let reference = course.reference(key, lecture_id)?;
let supplemental = match reading.role {
coursebank::course::ReadingRole::Supplemental => " (supplemental)",
coursebank::course::ReadingRole::Assigned => "",
};
println!(
" {lecture_id} {}{supplemental}",
reading.cite(key, reference)
);
if let Some(focus) = &reading.focus {
println!(
" {}",
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
);
}
}
}
let gaps = course.objectives_without_readings();
if gaps.is_empty() {
if !quiet {
println!("\nevery assessed objective has a reading behind it");
}
return Ok(Outcome::Ok);
}
println!(
"\n{} assessed objective(s) with no reading, so a student report cannot say \
where to go back to:",
gaps.len()
);
for id in gaps {
println!(" - {id}");
}
Ok(Outcome::Findings)
}
+224 -2
View File
@@ -9,7 +9,9 @@
//! checking — [`validate`] for problems that must be fixed and [`lint`] for
//! item-writing guidance. [`catalog`] summarizes the pool that results.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use coursebank::assessment::AssessmentFile;
use coursebank::bank::BankFile;
@@ -44,6 +46,9 @@ reports/
*.swp
";
/// Marks the block `init` prepends to a `.gitignore` that was already there.
const GITIGNORE_HEADER: &str = "# Added by coursebank init.";
/// `init`: create a new course directory, refusing to clobber an existing one.
pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
@@ -70,7 +75,7 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
println!("wrote {}", bank_path.display());
}
yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?;
write_gitignore(&cli.course.join(".gitignore"))?;
println!(
"\nNext: edit {} to add your learning objectives and lectures, then\n \
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
@@ -79,6 +84,153 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
Ok(Outcome::Ok)
}
/// What reconciling [`GITIGNORE`] against a file already on disk would do.
struct GitignoreMerge {
/// The file to write. Identical to the input when nothing was missing.
text: String,
/// The patterns that were missing, in the order [`GITIGNORE`] lists them.
added: Vec<String>,
/// Patterns the file un-ignores with a `!` rule, which are left out. Git
/// applies the last matching rule, so a line added at the top would lose.
negated: Vec<String>,
}
/// The comparison key for one `.gitignore` line, or `None` for a blank or comment.
///
/// `build`, `build/`, and `/build/` are one pattern spelled three ways, so the key
/// drops the slashes. A leading `!` stays, because `!build` is the opposite of
/// `build` rather than a restatement of it.
fn pattern_key(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let (bang, rest) = match trimmed.strip_prefix('!') {
Some(rest) => ("!", rest.trim_start()),
None => ("", trimmed),
};
let rest = rest.trim_start_matches('/').trim_end_matches('/');
if rest.is_empty() {
return None;
}
Some(format!("{bang}{rest}"))
}
/// Reconciles [`GITIGNORE`] against a `.gitignore` that is already on disk.
///
/// Patterns the file already has are skipped, and a block of [`GITIGNORE`] left
/// with no patterns loses its comment too, so nobody ends up with a heading over
/// nothing. What survives goes above the existing content, which is copied
/// through unchanged, including its line endings.
fn merge_gitignore(existing: &str) -> GitignoreMerge {
let keys: BTreeSet<String> = existing.lines().filter_map(pattern_key).collect();
let crlf = existing.contains("\r\n");
let newline = if crlf { "\r\n" } else { "\n" };
let mut block: Vec<&str> = Vec::new();
let mut pending: Vec<&str> = Vec::new();
let mut added: Vec<String> = Vec::new();
let mut negated: Vec<String> = Vec::new();
let mut kept_in_block = false;
for line in GITIGNORE.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
// A blank line starts a new block, so any comment still waiting for a
// pattern belonged to a block that was dropped entirely.
pending.clear();
kept_in_block = false;
continue;
}
if trimmed.starts_with('#') {
pending.push(trimmed);
continue;
}
let Some(key) = pattern_key(trimmed) else {
continue;
};
if keys.contains(&key) {
continue;
}
if keys.contains(&format!("!{key}")) {
negated.push(trimmed.to_string());
continue;
}
if !kept_in_block && !block.is_empty() {
block.push("");
}
block.append(&mut pending);
kept_in_block = true;
block.push(trimmed);
added.push(trimmed.to_string());
}
let mut text = String::new();
if !block.is_empty() {
for line in std::iter::once(GITIGNORE_HEADER).chain(block).chain([""]) {
text.push_str(line);
text.push_str(newline);
}
}
text.push_str(existing);
GitignoreMerge {
text,
added,
negated,
}
}
/// Writes the `.gitignore`, merging into one that is already there.
///
/// `init` is often run in a repository that already has a `.gitignore`, and the
/// first version of this overwrote it. An existing file now keeps everything it
/// had and gains only the patterns it was missing, at the top where they are easy
/// to see in the diff.
fn write_gitignore(path: &Path) -> Result<()> {
let existing = match fs::read_to_string(path) {
Ok(text) => text,
// Nothing to merge with. Stay quiet about it, the way this always has.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return yaml::write_text(path, GITIGNORE);
}
// A file that is there but unreadable, or not UTF-8, is not one to
// replace on a guess.
Err(e) => return Err(Error::io(path, e)),
};
let merge = merge_gitignore(&existing);
if merge.added.is_empty() {
println!(
"{} already has every pattern init would add",
path.display()
);
} else {
yaml::write_text(path, &merge.text)?;
println!(
"added {} pattern(s) to the top of {}: {}",
merge.added.len(),
path.display(),
merge.added.join(" ")
);
}
for pattern in &merge.negated {
println!(
"note: {} un-ignores `{pattern}`, and the last matching rule wins, \
so init did not add it",
path.display()
);
if pattern.contains("salt") {
println!(
" that rule will commit the pseudonymization salt; \
remove it before you push"
);
}
}
Ok(())
}
/// `schema`: (re)write the JSON Schemas an editor uses to validate the YAML.
pub(crate) fn schema(cli: &Cli) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
@@ -271,4 +423,74 @@ mod tests {
assert!(GITIGNORE.contains(".coursebank-salt"));
assert!(GITIGNORE.contains("build/"));
}
#[test]
fn merging_keeps_the_existing_file_and_adds_only_what_was_missing() {
let existing = "# rules I wrote\ntarget/\nbuild/\n*.pdf\n";
let merge = merge_gitignore(existing);
assert!(
merge.text.ends_with(existing),
"the existing file must survive byte for byte:\n{}",
merge.text
);
assert!(merge.text.starts_with(GITIGNORE_HEADER));
assert!(!merge.added.iter().any(|p| p == "build/" || p == "*.pdf"));
assert!(merge.added.iter().any(|p| p == "reports/"));
assert!(merge.added.iter().any(|p| p == ".coursebank-salt"));
}
#[test]
fn a_block_with_nothing_left_to_add_loses_its_comment() {
let merge = merge_gitignore("build/\nreports/\n");
assert!(!merge.text.contains("# Generated output"));
assert!(merge.text.contains("# Typst and PDF artifacts."));
}
#[test]
fn slashes_do_not_make_a_pattern_look_new() {
let merge = merge_gitignore("/build\nreports\n/data/\n");
assert!(
!merge.added.iter().any(|p| p.contains("build")),
"`/build` already covers `build/`, so it must not be added again"
);
assert!(!merge.added.iter().any(|p| p.contains("reports")));
}
#[test]
fn a_complete_file_is_left_exactly_as_it_was() {
let merge = merge_gitignore(GITIGNORE);
assert!(merge.added.is_empty());
assert_eq!(merge.text, GITIGNORE);
}
#[test]
fn a_negated_pattern_is_reported_instead_of_reinserted() {
let merge = merge_gitignore("*.pdf\n!*.salt\n");
assert_eq!(merge.negated, vec!["*.salt".to_string()]);
assert!(!merge.added.iter().any(|p| p == "*.salt"));
// The un-ignore covers one spelling of the salt, not the other.
assert!(merge.added.iter().any(|p| p == ".coursebank-salt"));
}
#[test]
fn line_endings_follow_the_file_being_merged_into() {
let merge = merge_gitignore("target/\r\n");
assert_eq!(
merge.text.matches('\n').count(),
merge.text.matches("\r\n").count(),
"a CRLF file must not gain bare LF lines:\n{:?}",
merge.text
);
}
#[test]
fn comments_and_blank_lines_are_not_patterns() {
assert_eq!(pattern_key(" build/ ").as_deref(), Some("build"));
assert_eq!(pattern_key("/build/").as_deref(), Some("build"));
assert_eq!(pattern_key("!build").as_deref(), Some("!build"));
assert_eq!(pattern_key("# build/"), None);
assert_eq!(pattern_key(" "), None);
assert_eq!(pattern_key("/"), None);
}
}
-1
View File
@@ -29,5 +29,4 @@ pub mod canvas;
pub mod gradescope;
pub mod responses;
pub mod store;
#[cfg(feature = "parquet")]
pub mod store_parquet;
+2 -31
View File
@@ -49,21 +49,14 @@ impl Format {
///
/// Parquet when the `parquet` feature is on, CSV otherwise.
pub fn preferred() -> Format {
#[cfg(feature = "parquet")]
{
Format::Parquet
}
#[cfg(not(feature = "parquet"))]
{
Format::Csv
}
Format::Parquet
}
/// Whether this format can be written by the current build.
pub fn is_available(self) -> bool {
match self {
Format::Csv => true,
Format::Parquet => cfg!(feature = "parquet"),
Format::Parquet => true,
}
}
@@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result<ResponseSet> {
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
crate::store_parquet::write(path, rows)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
Err(Error::FeatureDisabled("Parquet", "parquet"))
}
/// Reads flat responses from Parquet.
///
/// # Arguments
@@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
let rows = crate::store_parquet::read(path)?;
let mut set = ResponseSet::new();
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result<ResponseSet> {
Ok(set)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
Err(Error::Other(format!(
"{} is a Parquet file, but this build has Parquet support compiled out; \
rebuild with `--features parquet`, or re-ingest with `--format csv`",
path.display()
)))
}
/// Makes an administration id usable as a file name.
///
/// # Arguments
+6
View File
@@ -8,7 +8,10 @@
//! |:--|:--|:--|
//! | [`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 |
//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions |
//! | [`report`] | Markdown and HTML | students, and yourself |
//! | [`lecture`] | Markdown | the reading list on the course website |
//!
//! [`qti`] and [`typst`] share one rule that is easy to get wrong: a form's answer
//! key must be generated from the same permutation that produced its question
@@ -20,6 +23,9 @@
//! answers, other students' data, and any numeric rank.
//! 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 site;
pub mod typst;
+414
View File
@@ -0,0 +1,414 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering a lecture's objectives and readings as Markdown.
//!
//! The course file is the source of truth for what a lecture assigns and why, so
//! the reading list on the course website is generated rather than kept in step by
//! hand. Two copies of the same prose drift within a term; one copy and a build
//! step do not.
//!
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
//! Those numbers are positional and so cannot be authored: inserting an objective
//! renumbers everything after it. They are computed here and never stored.
//!
//! What this module does not do is invent prose. Everything printed comes from
//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
//! none of the three renders as a bare citation.
use crate::course::{CourseFile, Reading, ReadingRole, Reference};
use crate::error::{Error, Result};
use crate::taxonomy::Level;
/// Which flavour of Markdown to emit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Style {
/// Pandoc definition lists with `<br>` before the objective line, which is
/// what a Quarto lecture page uses.
#[default]
Quarto,
/// Plain Markdown bullets, for a report or a README.
Plain,
}
/// Renders the objectives for one lecture, grouped by level.
///
/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
/// disagree. Generating one half of the page and hand-writing the other is how you
/// get a note pointing at LO 8 when LO 8 has become LO 9.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are
/// grouped last under no heading.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the lecture id is not registered.
pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
course.lecture(lecture, "lecture page")?;
let ids = course.lecture_objectives(lecture);
let mut out = String::from("## Learning objectives\n\n");
out.push_str("After this lecture, you should be able to do the following.\n\n");
// Levels in taxonomy order, then whatever declares no ceiling.
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
groups.push((None, Vec::new()));
for id in &ids {
let ceiling = course.learning_objectives[*id].level_ceiling;
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
slot.1.push(id);
}
}
for (level, members) in &groups {
if members.is_empty() {
continue;
}
if let Some(level) = level {
out.push_str(&format!("### {}\n\n", level.name()));
}
for id in members {
let text = course.objective_text(id);
out.push_str(&match style {
Style::Quarto => format!("(@) {text}\n"),
Style::Plain => format!("1. {text}\n"),
});
}
out.push('\n');
}
Ok(out)
}
/// Renders the readings for one lecture.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id, such as `L1.1`.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The Markdown, ending in a newline. Supplemental readings follow the assigned
/// ones under their own subheading, and are omitted entirely when there are none.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the lecture id or a cited reference is not
/// registered.
pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
let lec = course.lecture(lecture, "lecture page")?;
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
// `_(LO ...)_` agree with the objective list printed above them.
let order = course.lecture_objectives(lecture);
let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
let mut out = String::from("## Readings\n\n");
for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
let group: Vec<&Reading> = lec.readings.iter().filter(|r| r.role == role).collect();
if group.is_empty() {
continue;
}
if role == ReadingRole::Supplemental {
out.push_str("### Supplemental\n\n");
}
for reading in group {
out.push_str(&entry(course, reading, style, &number)?);
}
}
Ok(out)
}
/// Renders one reading.
///
/// # Arguments
///
/// * `course` - the course, for resolving references and placeholders.
/// * `reading` - the reading.
/// * `style` - which flavour to emit.
/// * `number` - the position of an objective on this page, if it has one.
///
/// # Returns
///
/// The entry, followed by a blank line.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the cited reference is not registered.
fn entry(
course: &CourseFile,
reading: &Reading,
style: Style,
number: &impl Fn(&str) -> Option<usize>,
) -> Result<String> {
// A reading carried over from the old string form has nothing to resolve.
if let (None, Some(text)) = (&reading.reference, &reading.text) {
return Ok(match style {
Style::Quarto => format!("{text}\n\n"),
Style::Plain => format!("- {text}\n"),
});
}
let key = reading
.reference
.as_deref()
.ok_or_else(|| Error::other("reading has neither a reference nor text"))?;
let reference = course.reference(key, "lecture page")?;
let mut out = String::new();
out.push_str(&heading(reading, key, reference, style));
// The three prose fields in the order a reader wants them: what it is, what to
// take from it, what to leave.
let body: Vec<String> = [&reading.summary, &reading.focus, &reading.skip]
.into_iter()
.flatten()
.map(|prose| {
course.expand_objective_refs(prose, |id| match number(id) {
Some(n) => format!("LO {n}"),
None => course.objective_text(id),
})
})
.collect();
match style {
Style::Quarto => {
if !body.is_empty() {
out.push_str(&format!(": {}\n", body.join("\n")));
}
let mut numbers: Vec<usize> = reading
.objectives
.iter()
.filter_map(|o| number(o))
.collect();
numbers.sort_unstable();
if !numbers.is_empty() {
let list: Vec<String> = numbers.iter().map(|n| n.to_string()).collect();
out.push_str(&format!("<br>\n_(LO {})_\n", list.join(", ")));
}
out.push('\n');
}
Style::Plain => {
if !body.is_empty() {
out.push_str(&format!(" {}\n", body.join(" ")));
}
}
}
Ok(out)
}
/// The citation line that opens an entry.
///
/// # Arguments
///
/// * `reading` - the reading.
/// * `key` - its citation key.
/// * `reference` - the cited work.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// A linked citation when the location has a URL, and a plain one when it does not.
fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) -> String {
let label = reference.label.as_deref().unwrap_or(key);
let locator = reading.locator.as_deref().unwrap_or("");
let linked = match reading.resolve_url(reference) {
Some(url) if !locator.is_empty() => format!("[{locator}]({url})"),
Some(url) => format!("[{}]({url})", reference.title),
None if !locator.is_empty() => locator.to_string(),
None => reference.title.clone(),
};
match style {
Style::Quarto => format!("`{label}` {linked}\n"),
Style::Plain => format!("- **{label}** {linked}\n"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::course::{Lecture, Objective, ReferenceRole};
/// A course with one lecture, two objectives, and one reference.
fn course() -> CourseFile {
let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
c.lectures.clear();
c.learning_objectives.clear();
c.references.insert(
"kuriyan2013molecules".into(),
Reference {
label: Some("KKW".into()),
role: ReferenceRole::Required,
title: "The molecules of life".into(),
base_url: Some("https://example.org/kkw/".into()),
..Reference::default()
},
);
for (id, order) in [("lo-second", 2), ("lo-first", 1)] {
c.learning_objectives.insert(
id.into(),
Objective {
text: format!("objective {id}"),
lectures: vec!["L1.1".into()],
order: Some(order),
..objective_defaults()
},
);
}
c.lectures.insert(
"L1.1".into(),
Lecture {
title: "Enthalpy".into(),
date: None,
unit: None,
slides_url: None,
readings: vec![
Reading {
reference: Some("kuriyan2013molecules".into()),
locator: Some("§6.1".into()),
path: Some("6/A/#1".into()),
objectives: vec!["lo-second".into(), "lo-first".into()],
summary: Some("What a system is.".into()),
focus: Some("A worked instance of {lo-first}.".into()),
..Reading::default()
},
Reading {
reference: Some("kuriyan2013molecules".into()),
locator: Some("§1.9".into()),
path: Some("1/B/#9".into()),
role: ReadingRole::Supplemental,
objectives: vec!["lo-second".into()],
summary: Some("Background.".into()),
..Reading::default()
},
],
},
);
c
}
/// The non-defaulted half of an objective, so the fixtures stay short.
fn objective_defaults() -> Objective {
Objective {
text: String::new(),
unit: None,
lectures: Vec::new(),
order: None,
level_ceiling: None,
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
}
}
#[test]
fn the_quarto_form_matches_the_page_it_replaces() {
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Readings
`KKW` [§6.1](https://example.org/kkw/6/A/#1)
: What a system is.
A worked instance of LO 1.
<br>
_(LO 1, 2)_
### Supplemental
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
: Background.
<br>
_(LO 2)_
";
assert_eq!(md, expected);
}
#[test]
fn objective_numbers_follow_teaching_order_not_id_order() {
// `lo-second` sorts first alphabetically and second by `order`.
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("_(LO 1, 2)_"));
assert!(md.contains("A worked instance of LO 1."));
}
#[test]
fn objectives_group_by_level_in_taxonomy_order() {
let mut c = course();
c.learning_objectives
.get_mut("lo-first")
.expect("fixture")
.level_ceiling = Some(Level::Remember);
c.learning_objectives
.get_mut("lo-second")
.expect("fixture")
.level_ceiling = Some(Level::Apply);
let md = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Learning objectives
After this lecture, you should be able to do the following.
### Remember
(@) objective lo-first
### Apply
(@) objective lo-second
";
assert_eq!(md, expected);
}
#[test]
fn an_objective_with_no_level_still_appears() {
// Ungrouped, at the end, rather than silently dropped.
let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("(@) objective lo-first"));
assert!(md.contains("(@) objective lo-second"));
assert!(!md.contains("###"));
}
#[test]
fn a_supplemental_heading_appears_only_when_something_is_under_it() {
let mut c = course();
c.lectures.get_mut("L1.1").expect("lecture").readings.pop();
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
assert!(!md.contains("Supplemental"));
}
#[test]
fn a_legacy_string_reading_still_renders() {
let mut c = course();
let readings = &mut c.lectures.get_mut("L1.1").expect("lecture").readings;
readings.clear();
readings.push(Reading {
text: Some("KKW §6.1: system and surroundings. https://example.org".into()),
..Reading::default()
});
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("system and surroundings"));
}
#[test]
fn an_unknown_reference_is_an_error_rather_than_a_blank() {
let mut c = course();
c.references.clear();
let err = readings_markdown(&c, "L1.1", Style::Quarto);
assert!(err.is_err());
}
}
+669
View File
@@ -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\""));
}
}
+400 -11
View File
@@ -47,9 +47,7 @@ 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 +153,102 @@ fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', "&quot;")
}
// ---------------------------------------------------------------------------
/// Renders authoring markup to HTML for Canvas, remapping math delimiters to the
/// forms Canvas's MathJax recognizes.
///
/// Canvas loads MathJax and typesets a text field only when it finds one of its
/// own delimiters: `\( … \)` inline, or `$$ … $$` as a display block. Bare `$ … $`
/// is not a delimiter Canvas reads, so the tool's authoring convention (`$ … $`
/// inline) would otherwise reach the quiz as literal dollar-sign text. Here inline
/// `$ … $` becomes `\( … \)` and display `$$ … $$` is left as it is.
///
/// The split runs before inline markup, the same way the site export handles it,
/// so a subscript like `$q_p$` is not read as an emphasis span. Apart from the
/// math, the paragraph and line handling matches [`markup::to_html`].
fn to_html_canvas(src: &str) -> String {
let paragraphs: Vec<String> = src
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| {
let joined = p
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
format!("<p>{}</p>", canvas_segments(&joined))
})
.collect();
if paragraphs.is_empty() {
String::new()
} else {
paragraphs.join("\n")
}
}
/// Splits one line on math delimiters and renders each run: prose through the
/// shared escape, symbol, and inline-markup pipeline; math wrapped in the Canvas
/// delimiter for its kind, its interior escaped for HTML transport so a `<` inside
/// math survives as `&lt;` and is decoded back before MathJax reads it.
fn canvas_segments(line: &str) -> String {
let mut out = String::new();
let mut rest = line;
while let Some(at) = rest.find('$') {
if at > 0 {
out.push_str(&canvas_prose(&rest[..at]));
}
let after = &rest[at..];
if let Some(display) = after.strip_prefix("$$") {
if let Some(end) = display.find("$$") {
out.push_str(&format!("$${}$$", markup::escape_html(&display[..end])));
rest = &display[end + 2..];
continue;
}
}
let inline = &after[1..];
match inline.find('$') {
Some(end) => {
out.push_str(&format!("\\({}\\)", markup::escape_html(&inline[..end])));
rest = &inline[end + 1..];
}
None => {
// An unterminated `$` is ordinary text.
out.push_str(&canvas_prose(after));
rest = "";
}
}
}
if !rest.is_empty() {
out.push_str(&canvas_prose(rest));
}
out
}
/// A prose run: escape, the symbol table, then inline markup, the pipeline
/// [`markup::to_html`] uses.
fn canvas_prose(text: &str) -> String {
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
}
/// The per-option feedback to show, given the attempt policy.
///
/// A single-attempt quiz is summative, so it shows the full post-submission
/// feedback ([`crate::item::Choice::student_text`]: the misconception and
/// explanation). A
/// multi-attempt quiz is formative, so it shows only the hint, withholding the
/// misconception a student would otherwise read before their next try. When a
/// distractor has no hint, a multi-attempt quiz shows nothing for it rather than
/// falling back to the misconception.
fn option_feedback<'a>(choice: &'a crate::item::Choice, opts: &QtiOptions) -> Option<&'a str> {
if opts.attempts == 1 {
choice.student_text()
} else {
choice.hint.as_deref()
}
}
// Package construction
// ---------------------------------------------------------------------------
/// Options for a QTI export.
#[derive(Debug, Clone)]
@@ -170,7 +261,9 @@ pub struct QtiOptions {
pub include_feedback: bool,
/// Whether to let Canvas shuffle answers on top of the form's own order.
pub shuffle_in_canvas: bool,
/// Maximum attempts; `-1` for unlimited.
/// Maximum attempts; `-1` for unlimited. More than one attempt also switches
/// per-option feedback from the misconception to the hint, so a formative
/// quiz nudges rather than reveals.
pub attempts: i64,
/// How repeated attempts are scored.
pub scoring_policy: ScoringPolicy,
@@ -251,7 +344,10 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
for placement in select::layout(record, &opts.form) {
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
if item.key_indices().is_empty() {
// A choice question needs a keyed option or Canvas cannot score it. An
// open-response question has no options and is graded by hand, so the
// absence of a key is expected; it exports as a Canvas essay.
if item.format.has_options() && item.key_indices().is_empty() {
problems.push(format!(
"question {} ({}) has no keyed option, so Canvas cannot score it",
placement.number, placement.item
@@ -338,6 +434,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();
@@ -367,14 +468,14 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
.map(|(o, id)| {
Node::new("response_label")
.attr("ident", id.clone())
.child(mattext(&markup::to_html(&o.text)))
.child(mattext(&to_html_canvas(&o.text)))
})
.collect();
let presentation = Node::new("presentation")
.child(mattext(&format!(
"<div>{}</div>",
markup::to_html(&item.stem)
to_html_canvas(&item.stem)
)))
.child(
Node::new("response_lid")
@@ -398,7 +499,7 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
// feedback. `continue="Yes"` is what allows scoring to be evaluated after.
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if o.student_text().is_none() {
if option_feedback(o, opts).is_none() {
continue;
}
resprocessing = resprocessing.child(
@@ -483,13 +584,13 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if let Some(text) = o.student_text() {
if let Some(text) = option_feedback(o, opts) {
node = node.child(
Node::new("itemfeedback")
.attr("ident", format!("{id}_fb"))
.child(
Node::new("flow_mat")
.child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
.child(mattext(&format!("<div>{}</div>", to_html_canvas(text)))),
),
);
}
@@ -499,6 +600,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>",
to_html_canvas(&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>", to_html_canvas(text)))),
));
}
node
}
/// A `<material><mattext texttype="text/html">` pair.
///
/// # Arguments
@@ -687,4 +886,194 @@ mod tests {
assert_eq!(slug_filename("exam-4 2026s"), "exam-4_2026s");
assert_eq!(slug_filename(""), "quiz");
}
#[test]
fn canvas_math_uses_mathjax_delimiters() {
// Inline $...$ becomes \( ... \), which Canvas typesets in the text flow.
assert_eq!(
to_html_canvas("Enthalpy, $\\Delta H$"),
"<p>Enthalpy, \\(\\Delta H\\)</p>"
);
// Display $$...$$ is left as a Canvas block.
assert_eq!(to_html_canvas("$$E = mc^2$$"), "<p>$$E = mc^2$$</p>");
// A subscript inside math is not read as emphasis.
assert_eq!(
to_html_canvas("$q_p = \\Delta H$"),
"<p>\\(q_p = \\Delta H\\)</p>"
);
// A `<` inside math is escaped for transport; Canvas decodes it before
// MathJax reads it.
assert_eq!(to_html_canvas("$a < b$"), "<p>\\(a &lt; b\\)</p>");
// Prose with no math is escaped and wrapped, same as `to_html`.
assert_eq!(to_html_canvas("a & b"), "<p>a &amp; b</p>");
}
fn distractor() -> crate::item::Choice {
crate::item::Choice {
id: "B".into(),
text: "Internal energy".into(),
correct: false,
credit: None,
explanation: Some("Only at constant volume.".into()),
hint: Some("Reconsider what stays constant in an open flask.".into()),
misconception: Some("Uses the constant-volume result.".into()),
error_type: None,
defensible: false,
defense: None,
feedback_student: None,
selection_rate_expected: None,
}
}
#[test]
fn one_attempt_shows_the_misconception_and_more_show_the_hint() {
let choice = distractor();
let mut opts = QtiOptions {
attempts: 1,
..QtiOptions::default()
};
// student_text() prefers feedback_student, then the misconception.
assert_eq!(
option_feedback(&choice, &opts),
Some("Uses the constant-volume result.")
);
opts.attempts = 3;
assert_eq!(
option_feedback(&choice, &opts),
Some("Reconsider what stays constant in an open flask.")
);
opts.attempts = -1; // unlimited is also multi-attempt
assert!(
option_feedback(&choice, &opts)
.unwrap()
.starts_with("Reconsider")
);
// Multiple attempts with no hint show nothing, so the misconception is
// not revealed before the next try.
let no_hint = crate::item::Choice {
hint: None,
..distractor()
};
assert_eq!(option_feedback(&no_hint, &opts), None);
}
fn catalog(tag: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("cb-qti-{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 }
lectures:
L1.1: { title: Enthalpy }
learning_objectives:
lo-enthalpy:
text: Define enthalpy.
lectures: [L1.1]
order: 1
"#,
)
.unwrap();
std::fs::write(
dir.join("banks").join("b.yaml"),
r#"
bank: { id: b, title: Bank }
items:
- id: q-mcq
status: draft
level: 2
format: single_best_answer
stem: "The heat at constant pressure equals a change in what?"
learning_objectives: [lo-enthalpy]
options:
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true }
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Constant-volume result." }
- id: q-open
status: draft
level: 3
format: open_response
stem: "Show why $q_p = \\Delta H$."
learning_objectives: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
"#,
)
.unwrap();
Catalog::load(&dir).expect("catalog loads")
}
fn record() -> AssessmentFile {
use crate::assessment::{Assessment, Kind, Placement, Platform};
AssessmentFile {
schema_version: "1.0".into(),
assessment: Assessment {
id: "a1.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: "b::q-mcq".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: "b::q-open".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 an_open_response_item_exports_as_a_canvas_essay() {
let cat = catalog("essay");
let rec = record();
let pkg = build(&cat, &rec, &QtiOptions::default())
.expect("an essay item does not block the export");
// Both questions survive: the choice question and the open-response essay.
assert!(
pkg.quiz_xml.contains("multiple_choice_question"),
"the choice question should be present"
);
assert!(
pkg.quiz_xml.contains("essay_question"),
"the open-response item should export as a Canvas essay"
);
// The essay stem's math is rendered with Canvas delimiters, and the model
// answer rides along as feedback.
assert!(pkg.quiz_xml.contains("\\(q_p = \\Delta H\\)"));
assert!(pkg.quiz_xml.contains("H = U + PV"));
}
}
+11 -11
View File
@@ -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(
+976
View File
@@ -0,0 +1,976 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering an assessment for a Quarto course website, with solutions gated
//! behind a per-page password.
//!
//! This is the path that publishes to the web rather than to Canvas or a printed
//! exam. It produces three things for one assessment:
//!
//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an
//! empty, hidden `.qsol` slot per item. A page includes it with
//! `{{< include _questions.qmd >}}`. It carries no answers.
//! 2. A `<id>-solutions.json` bundle: every solution rendered to an HTML fragment,
//! then encrypted. The ciphertext ships in the page, but the plaintext never
//! does, so a student cannot read answers from the source or the network tab.
//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle.
//! It is printed for the instructor and stored nowhere, so it cannot be
//! recovered from the files. Hand it out, and rotate it after a due date.
//!
//! The browser side is [`assets`]: `questions.css` styles the questions and the
//! unlocked solutions, and `solutions.js` derives the key from the typed password,
//! decrypts, and injects each fragment. The crypto here matches that script byte
//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and
//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag
//! appended to the ciphertext. Get any parameter wrong and a correct password
//! would fail to authenticate.
//!
//! Two rules carry over from the other exporters. A question paper never contains
//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in
//! the encrypted bundle. And option order comes from the form's seed, so the
//! printed letters in the questions and the letters the solution refers to are the
//! same order.
use crate::assessment::{AssessmentFile, Form, Placement};
use crate::catalog::Catalog;
use crate::course::{CourseFile, Reference};
use crate::error::{Error, Result};
use crate::item::{Choice, Citation, Item, Solution};
use crate::markup;
use crate::select;
use crate::taxonomy::Format;
use aes_gcm::Aes256Gcm;
use aes_gcm::aead::generic_array::GenericArray;
use aes_gcm::aead::{Aead, KeyInit};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`.
const ITERATIONS: u32 = 250_000;
/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols
/// means five bits each, so sixteen of them carry eighty bits of entropy.
const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// The bundled browser assets, installed once per site (not per page).
///
/// # Returns
///
/// Pairs of file name and verbatim contents: the stylesheet and the unlock
/// script. Write them into the site's static directory and wire them in
/// `_quarto.yml`.
pub fn assets() -> [(&'static str, &'static str); 2] {
[
(
"questions.css",
include_str!("../assets/site/questions.css"),
),
("solutions.js", include_str!("../assets/site/solutions.js")),
]
}
/// How to render one assessment for the site.
#[derive(Debug, Clone)]
pub struct Options {
/// The form whose option order to print.
pub form: Form,
/// A password to encrypt with. `None` generates a fresh random one, which is
/// the intended path; pass `Some` only to re-encrypt a bundle with a known
/// password.
pub password: Option<String>,
}
impl Options {
/// Builds options for a form, generating the password.
///
/// # Arguments
///
/// * `form` - the form whose option order to print.
///
/// # Returns
///
/// Options that will generate a fresh password at render time.
pub fn new(form: Form) -> Options {
Options {
form,
password: None,
}
}
}
/// Everything one render produces, ready to write.
#[derive(Debug, Clone)]
pub struct Rendered {
/// The assessment id, used for the bundle file name and the gate's
/// `data-bundle` attribute.
pub page: String,
/// The `_questions.qmd` partial.
pub questions_qmd: String,
/// The `<id>-solutions.json` bundle, serialized and newline-terminated.
pub solutions_json: String,
/// The password that decrypts the bundle. Print it; it is stored nowhere.
pub password: String,
}
/// Renders an assessment into the questions partial and the encrypted bundle.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for items, objectives, and references.
/// * `record` - the assembled assessment.
/// * `opts` - the form and an optional password.
///
/// # Returns
///
/// The partial, the serialized bundle, and the password.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement names an item the catalog does
/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization
/// failure.
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result<Rendered> {
let page = record.assessment.id.clone();
let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?;
let fragments = solution_fragments(catalog, record, &opts.form)?;
let password = match opts.password {
Some(p) => p,
None => gen_password()?,
};
let bundle = build_bundle(&page, &password, &fragments)?;
let json = serde_json::to_string_pretty(&bundle)
.map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?;
Ok(Rendered {
page,
questions_qmd,
solutions_json: format!("{json}\n"),
password,
})
}
/// Builds the `_questions.qmd` partial.
fn questions_qmd(
catalog: &Catalog,
record: &AssessmentFile,
form: &Form,
page: &str,
) -> Result<String> {
let mut out = String::new();
out.push_str(
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
\x20 matter, no title, no prose. Include from the page with:\n\
\x20 {{< include _questions.qmd >}} -->\n\n",
);
out.push_str(&format!(
"::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n"
));
let mut number = 0usize;
let default_points = catalog.course.policy.points_per_item;
let layout = select::layout(record, form);
for placement in layout.iter().filter(|p| !p.dropped) {
let item = &catalog.require(&placement.item)?.item;
number += 1;
out.push_str(&question_block(
number,
placement,
item,
form,
default_points,
));
out.push('\n');
}
// Leave exactly one trailing newline.
while out.ends_with("\n\n") {
out.pop();
}
Ok(out)
}
/// One `.q` block: head, stem, choices or a writing box, then the empty slot.
fn question_block(
number: usize,
placement: &Placement,
item: &Item,
form: &Form,
default_points: f64,
) -> String {
let id = &item.id;
let points = placement
.points
.unwrap_or_else(|| item.points(default_points));
let unit = if (points - 1.0).abs() < f64::EPSILON {
"point"
} else {
"points"
};
let mut b = String::new();
b.push_str(&format!("::: {{.q #{id}}}\n"));
b.push_str(":::: {.q-head}\n");
b.push_str(&format!(
"[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n",
kind_label(item.format),
trim_number(points),
));
b.push_str("::::\n\n");
b.push_str(":::: {.q-stem}\n");
b.push_str(&markup::to_markdown(&item.stem));
b.push_str("\n::::\n\n");
if item.has_options() {
b.push_str(":::: {.q-choices}\n");
let order = select::option_order(form, &placement.item, item.options.len());
for (position, &source) in order.iter().enumerate() {
b.push_str(&format!(
"{}. {}\n",
position + 1,
markup::to_markdown(&item.options[source].text)
));
}
b.push_str("::::\n\n");
} else {
b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n");
}
b.push_str(&format!(
":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n"
));
b.push_str(":::\n");
b
}
/// The human label for a format, shown in the question head.
fn kind_label(format: Format) -> &'static str {
match format {
Format::SingleBestAnswer => "Single best answer",
Format::MultipleResponse => "Multiple response",
Format::TrueFalse => "True or false",
Format::OpenResponse => "Open response",
}
}
// --- the solution fragments ---
/// Renders each item's solution to an HTML fragment, in printed order.
///
/// An item with nothing to show (an open-response question whose solution is
/// empty) is left out, so its slot simply never unlocks.
fn solution_fragments(
catalog: &Catalog,
record: &AssessmentFile,
form: &Form,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
let layout = select::layout(record, form);
for placement in layout.iter().filter(|p| !p.dropped) {
let item = &catalog.require(&placement.item)?.item;
if let Some(html) = fragment(&catalog.course, placement, item, form) {
out.push((item.id.clone(), html));
}
}
Ok(out)
}
/// The fragment for one item, or `None` when there is nothing to show.
fn fragment(
course: &CourseFile,
placement: &Placement,
item: &Item,
form: &Form,
) -> Option<String> {
if item.has_options() {
Some(choice_fragment(course, placement, item, form))
} else {
open_fragment(course, item)
}
}
/// A single-best-answer or multiple-response fragment: the key, the model answer,
/// the explanation, then per-distractor feedback.
fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
let order = select::option_order(form, &placement.item, item.options.len());
let printed: Vec<(usize, &Choice)> = order
.iter()
.enumerate()
.map(|(position, &source)| (position, &item.options[source]))
.collect();
let mut out = String::new();
let keyed: Vec<String> = printed
.iter()
.filter(|(_, c)| c.correct)
.map(|(position, c)| {
format!(
"<b>{}</b> &mdash; {}",
letter(*position),
inline_html(&c.text)
)
})
.collect();
out.push_str(
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
);
out.push_str(&keyed.join("; "));
out.push_str("</p>\n");
if let Some(solution) = item.solution.as_ref() {
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&format!(
"<p class=\"sol-explain\">{}</p>\n",
inline_html(explanation)
));
}
}
let distractors: Vec<(usize, &Choice)> = printed
.iter()
.copied()
.filter(|(_, c)| !c.correct)
.collect();
if distractors
.iter()
.any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some())
{
out.push_str("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
out.push_str("<ul class=\"sol-feedback\">\n");
for (position, c) in &distractors {
if c.misconception.is_none() && why_wrong(c).is_none() {
continue;
}
out.push_str(&format!(
" <li><span class=\"opt\">{}</span>\n",
letter(*position)
));
out.push_str(" <div class=\"opt-body\">\n");
if let Some(mis) = &c.misconception {
out.push_str(&format!(
" <span class=\"opt-mis\">{}</span>\n",
inline_html(mis)
));
}
if let Some(why) = why_wrong(c) {
out.push_str(&format!(
" <span class=\"opt-why\">{}</span>\n",
inline_html(why)
));
}
out.push_str(" </div></li>\n");
}
out.push_str("</ul>\n");
}
push_reference(&mut out, course, item.solution.as_ref());
out
}
/// An open-response fragment: the model answer, the rubric, accepted variants.
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
let mut out = String::new();
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if !solution.rubric.is_empty() {
let caption = match solution.rubric_points() {
Some(total) => {
let unit = if (total - 1.0).abs() < f64::EPSILON {
"point"
} else {
"points"
};
format!("Rubric &mdash; {} {unit}", trim_number(total))
}
None => "Rubric".to_string(),
};
out.push_str("<table class=\"sol-rubric\">\n");
out.push_str(&format!(" <caption>{caption}</caption>\n"));
out.push_str(
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
);
out.push_str(" <tbody>\n");
for criterion in &solution.rubric {
let pts = criterion.points.map(trim_number).unwrap_or_default();
out.push_str(&format!(
" <tr><td>{pts}</td><td>{}</td></tr>\n",
inline_html(&criterion.description)
));
}
out.push_str(" </tbody>\n</table>\n");
}
if !solution.accepted.is_empty() {
let joined = solution
.accepted
.iter()
.map(|a| inline_html(a))
.collect::<Vec<_>>()
.join("; ");
out.push_str(&format!(
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\n"
));
}
push_reference(&mut out, course, Some(solution));
Some(out)
}
/// The student-facing reason a distractor is wrong: the instructor explanation
/// first, then any student feedback.
fn why_wrong(choice: &Choice) -> Option<&str> {
choice
.explanation
.as_deref()
.or(choice.feedback_student.as_deref())
}
/// Appends the `Source:` line when the solution carries review citations.
fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) {
let Some(solution) = solution else { return };
if solution.review.is_empty() {
return;
}
let sources = solution
.review
.iter()
.map(|c| cite_html(course, c))
.collect::<Vec<_>>()
.join("; ");
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\n"));
}
/// Renders one citation to HTML, linking it when a URL resolves.
fn cite_html(course: &CourseFile, citation: &Citation) -> String {
if let Some(text) = &citation.text {
if citation.reference.is_none() {
return markup::escape_html(text);
}
}
let Some(key) = &citation.reference else {
return markup::escape_html(&citation.display());
};
let Some(reference) = course.references.get(key) else {
return markup::escape_html(&citation.display());
};
let label = reference.label.as_deref().unwrap_or(key);
let locator = citation.locator.as_deref().unwrap_or("");
let body = if locator.is_empty() {
markup::escape_html(label)
} else {
format!(
"{} {}",
markup::escape_html(label),
markup::escape_html(locator)
)
};
match resolve_url(citation, reference) {
Some(url) => format!("<a href=\"{}\">{body}</a>", markup::escape_html(&url)),
None => body,
}
}
/// Resolves a citation's link, from an explicit URL or a path joined to the
/// reference's base URL.
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}"),
})
}
// --- math-aware markup ---
/// One run of source text, split on math delimiters.
enum Segment<'a> {
/// Prose, formatted with the shared escape, symbol, and inline rules.
Text(&'a str),
/// A math span, passed through with its interior escaped for the browser.
Math { inner: &'a str, display: bool },
}
/// Splits source into prose and `$…$` or `$$…$$` math runs.
///
/// The split runs on raw source so a formatting rule can never reach inside math:
/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span.
fn split_math(src: &str) -> Vec<Segment<'_>> {
let mut segments = Vec::new();
let mut rest = src;
while let Some(at) = rest.find('$') {
if at > 0 {
segments.push(Segment::Text(&rest[..at]));
}
let after = &rest[at..];
if let Some(display) = after.strip_prefix("$$") {
if let Some(end) = display.find("$$") {
segments.push(Segment::Math {
inner: &display[..end],
display: true,
});
rest = &display[end + 2..];
continue;
}
}
let inline = &after[1..];
match inline.find('$') {
Some(end) => {
segments.push(Segment::Math {
inner: &inline[..end],
display: false,
});
rest = &inline[end + 1..];
}
None => {
// An unterminated `$` is treated as ordinary text.
segments.push(Segment::Text(after));
rest = "";
}
}
}
if !rest.is_empty() {
segments.push(Segment::Text(rest));
}
segments
}
/// Formats a prose run: HTML-escape, then the symbol table and inline markup.
fn format_prose(text: &str) -> String {
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
}
/// Emits a math run with its delimiters, escaping the interior so the browser
/// hands MathJax clean text (a `<` inside math becomes `&lt;`, which the DOM
/// decodes back before MathJax reads it).
fn render_math(inner: &str, display: bool) -> String {
let delim = if display { "$$" } else { "$" };
format!("{delim}{}{delim}", markup::escape_html(inner))
}
/// Converts authoring markup to an inline HTML string, preserving math.
///
/// No paragraph wrapping: the caller supplies the surrounding element.
fn inline_html(src: &str) -> String {
let mut out = String::new();
for segment in split_math(src.trim()) {
match segment {
Segment::Text(text) => out.push_str(&format_prose(text)),
Segment::Math { inner, display } => out.push_str(&render_math(inner, display)),
}
}
out
}
/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `<p>`.
fn block_html(src: &str) -> String {
src.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("<p>{}</p>", inline_html(p)))
.collect::<Vec<_>>()
.join("")
}
// --- the encrypted bundle -----
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
#[derive(Debug, Clone, Serialize)]
pub struct Bundle {
v: u8,
page: String,
kdf: Kdf,
cipher: &'static str,
items: OrderedItems,
}
#[derive(Debug, Clone, Serialize)]
struct Kdf {
name: &'static str,
hash: &'static str,
iterations: u32,
salt: String,
}
#[derive(Debug, Clone, Serialize)]
struct Enc {
iv: String,
ct: String,
}
/// Item entries serialized as a JSON object in insertion order, so the bundle
/// lists solutions in the order the questions appear rather than sorted by id.
#[derive(Debug, Clone)]
struct OrderedItems(Vec<(String, Enc)>);
impl Serialize for OrderedItems {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (id, enc) in &self.0 {
map.serialize_entry(id, enc)?;
}
map.end()
}
}
/// Generates a random per-bundle password.
///
/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy
/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`.
///
/// # Returns
///
/// The password, to print for the instructor.
///
/// # Errors
///
/// Returns [`Error::Other`] if the system CSPRNG is unavailable.
pub fn gen_password() -> Result<String> {
let mut raw = [0u8; 16];
csprng(&mut raw)?;
// Thirty-two divides 256, so the modulo is unbiased.
let symbols: Vec<u8> = raw
.iter()
.map(|b| PW_ALPHABET[(*b % 32) as usize])
.collect();
let groups: Vec<String> = symbols
.chunks(4)
.map(|c| String::from_utf8_lossy(c).into_owned())
.collect();
Ok(groups.join("-"))
}
/// Encrypts rendered fragments into a bundle.
///
/// # Arguments
///
/// * `page` - the assessment id, stored as `page` and echoed by the script.
/// * `password` - the password to derive the key from.
/// * `fragments` - item id and solution HTML, in the order to list them.
///
/// # Returns
///
/// The bundle, ready to serialize as `<page>-solutions.json`.
///
/// # Errors
///
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
let mut salt = [0u8; 16];
csprng(&mut salt)?;
let key = derive_key(password, &salt);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| Error::other("the derived AES key was the wrong length"))?;
let mut items = Vec::with_capacity(fragments.len());
for (id, html) in fragments {
let mut iv = [0u8; 12];
csprng(&mut iv)?;
let nonce = GenericArray::from_slice(&iv);
let ct = cipher
.encrypt(nonce, html.as_bytes())
.map_err(|_| Error::other("AES-GCM encryption failed"))?;
items.push((
id.clone(),
Enc {
iv: B64.encode(iv),
ct: B64.encode(ct),
},
));
}
Ok(Bundle {
v: 1,
page: page.to_string(),
kdf: Kdf {
name: "PBKDF2",
hash: "SHA-256",
iterations: ITERATIONS,
salt: B64.encode(salt),
},
cipher: "AES-GCM",
items: OrderedItems(items),
})
}
/// Derives the AES-256 key with PBKDF2-HMAC-SHA256.
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
let mut key = [0u8; 32];
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, ITERATIONS, &mut key);
key
}
/// Fills a buffer with cryptographically secure random bytes.
fn csprng(buf: &mut [u8]) -> Result<()> {
getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}")))
}
// --- small helpers -------
/// Formats a point value: no decimal when whole, at most two places otherwise.
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()
}
}
/// The printed letter for a position: 0 is A, 1 is B, and so on.
fn letter(position: usize) -> char {
char::from(b'A' + (position % 26) as u8)
}
#[cfg(test)]
mod tests {
use super::*;
fn form() -> Form {
Form {
id: "A".into(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
}
}
fn catalog(tag: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("cb-site-{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("b.yaml"),
r#"
bank: { id: b, title: Bank }
items:
- id: q-mcq
status: draft
level: 2
format: single_best_answer
stem: "The heat at constant pressure equals a change in what?"
learning_objectives: [lo-enthalpy]
options:
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
solution:
model_answer: "The change in enthalpy, $\\Delta H$."
review:
- { ref: kkw, locator: "§6.3" }
- id: q-open
status: draft
level: 3
format: open_response
stem: "Show why $q_p = \\Delta H$."
learning_objectives: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
rubric:
- { description: "States $H = U + PV$.", points: 1 }
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
accepted: ["$q_p = \\Delta H$ via $H = U + PV$"]
"#,
)
.unwrap();
Catalog::load(&dir).expect("catalog loads")
}
fn record() -> AssessmentFile {
use crate::assessment::{Assessment, Kind, Platform};
AssessmentFile {
schema_version: "1.0".into(),
assessment: Assessment {
id: "a1.1".into(),
title: "Homework 1".into(),
term: None,
kind: Kind::Homework,
date: None,
platform: Platform::Other,
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: "b::q-mcq".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: "b::q-open".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 questions_partial_has_no_front_matter_and_no_answers() {
let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap();
assert!(!out.starts_with("---"), "a partial carries no front matter");
assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}"));
assert!(out.contains("::: {.q #q-mcq}"));
assert!(
out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}")
);
// Choices are printed without letters; the correct flag never appears.
assert!(out.contains("1. Enthalpy, $\\Delta H$"));
assert!(!out.contains("correct"));
// The open-response question gets a writing box, both get an empty slot.
assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}"));
assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}"));
assert!(out.contains("[2 points]{.q-points}"));
// No model answer leaks into the questions.
assert!(!out.contains("sol-model"));
}
#[test]
fn a_choice_fragment_marks_the_key_and_explains_the_distractor() {
let cat = catalog("choice");
let rec = record();
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1;
assert!(mcq.contains("<span class=\"badge badge-correct\">Correct answer</span>"));
assert!(mcq.contains("<b>A</b> &mdash; Enthalpy, $\\Delta H$"));
assert!(mcq.contains(
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
));
assert!(mcq.contains("<span class=\"opt\">B</span>"));
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
}
#[test]
fn an_open_fragment_has_a_rubric_table_with_a_total() {
let cat = catalog("open");
let rec = record();
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
assert!(open.contains("<caption>Rubric &mdash; 2 points</caption>"));
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
}
#[test]
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
// A subscript inside math survives; angle brackets outside math are escaped.
assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ &lt; 5");
// Bold outside math becomes a tag; a dollar-math run is passed through.
assert_eq!(
inline_html("**H** is $H = U + PV$"),
"<strong>H</strong> is $H = U + PV$"
);
}
#[test]
fn the_bundle_round_trips_through_the_kdf_and_cipher() {
use aes_gcm::aead::generic_array::GenericArray;
let fragments = vec![
("q-mcq".to_string(), "<p>alpha</p>".to_string()),
("q-open".to_string(), "<p>beta</p>".to_string()),
];
let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap();
assert_eq!(bundle.v, 1);
assert_eq!(bundle.page, "a1.1");
assert_eq!(bundle.cipher, "AES-GCM");
assert_eq!(bundle.kdf.iterations, ITERATIONS);
// Insertion order is preserved in the serialized object.
let json = serde_json::to_string(&bundle).unwrap();
assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap());
// Decrypt the first item the way the browser would and check the plaintext.
let salt = B64.decode(&bundle.kdf.salt).unwrap();
let key = derive_key("enthalpy2026", &salt);
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
let (_, enc) = &bundle.items.0[0];
let iv = B64.decode(&enc.iv).unwrap();
let ct = B64.decode(&enc.ct).unwrap();
let pt = cipher
.decrypt(GenericArray::from_slice(&iv), ct.as_ref())
.unwrap();
assert_eq!(String::from_utf8(pt).unwrap(), "<p>alpha</p>");
}
#[test]
fn a_generated_password_has_the_expected_shape() {
let pw = gen_password().unwrap();
assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes
assert_eq!(pw.matches('-').count(), 3);
assert!(
pw.chars()
.all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8)))
);
}
#[test]
fn the_two_browser_assets_are_bundled() {
let assets = assets();
assert_eq!(assets[0].0, "questions.css");
assert_eq!(assets[1].0, "solutions.js");
assert!(assets[0].1.contains(".qsol"));
assert!(assets[1].1.contains("AES-GCM"));
}
}
+1 -5
View File
@@ -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.
+7 -1
View File
@@ -21,7 +21,9 @@
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
//! ingest, analyze, report.
//! 4. [`typst_export`] covers printed output, template markers, and render config.
//! 5. [`recipes`] holds short answers to specific questions, for when you already
//! 5. [`assignment`] publishes a homework to the course website, with solutions
//! gated behind a password.
//! 6. [`recipes`] holds short answers to specific questions, for when you already
//! know the shape of the tool.
//!
//! ## Why the tutorials are in here rather than a wiki
@@ -55,6 +57,10 @@ pub mod first_exam {}
#[doc = include_str!("../docs/TYPST.md")]
pub mod typst_export {}
/// Publishing an assignment to the website, with solutions gated behind a password.
#[doc = include_str!("../docs/guide/assignment.md")]
pub mod assignment {}
/// Short answers to specific questions.
#[doc = include_str!("../docs/guide/recipes.md")]
pub mod recipes {}
+2 -2
View File
@@ -104,13 +104,13 @@ pub use model::{assessment, bank, catalog, course, history, item, layout, taxono
pub use authoring::{jsonschema, lint, select};
#[cfg(feature = "parquet")]
pub use data::store_parquet;
pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
pub use export::{qti, report, typst};
pub use export::site;
pub use export::{lecture, practice, qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
+96 -11
View File
@@ -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(
+766 -5
View File
@@ -16,9 +16,12 @@
//! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::date::Date;
use crate::error::{Error, Result};
@@ -61,6 +64,12 @@ pub struct CourseFile {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub learning_objectives: BTreeMap<String, Objective>,
/// Works the course cites, keyed by citation key such as
/// `kuriyan2013molecules`. Readings point in here rather than restating a
/// citation, so a reference is written once and a changed edition is one edit.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub references: BTreeMap<String, Reference>,
/// Shared stimuli for case-based testlets, keyed by id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub stimuli: BTreeMap<String, Stimulus>,
@@ -172,9 +181,318 @@ pub struct Lecture {
/// Where the slides live, for study guidance in student reports.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slides_url: Option<String>,
/// Assigned readings for the session.
/// Assigned readings for the session, in the order you assign them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub readings: Vec<String>,
pub readings: Vec<Reading>,
}
/// A work the course cites: a textbook, an article, a dataset, a recording.
///
/// Keyed by citation key, so this registry is a bibliography rather than a second
/// naming scheme. The field names follow BibTeX where BibTeX has one, which makes
/// import and export mechanical.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reference {
/// The short form a reading list shows, such as `KKW`. Unique across the
/// registry, because reports print it in place of the key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// What kind of work this is, which decides how a citation renders.
#[serde(default)]
pub kind: ReferenceKind,
/// Whether the course requires it or lists it as background.
#[serde(default)]
pub role: ReferenceRole,
/// Full title.
pub title: String,
/// Authors as `Family, Given`, in the order printed on the work.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
/// Year of publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<u32>,
/// Edition as printed: `7th`, `Revised`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
/// Publisher, for a book.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// The journal, edited volume, or series this sits inside.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
/// Volume within the container.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volume: Option<String>,
/// Issue within the volume.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issue: Option<String>,
/// Page range of the work as a whole, not of any one reading.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pages: Option<String>,
/// DOI, bare: `10.1038/nature12373`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<String>,
/// ISBN, for a book.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isbn: Option<String>,
/// Canonical URL for the work as a whole.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Prefix a reading's `path` is appended to. Having this means the citation
/// key appears once rather than once per reading.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// Anything students need to know about getting hold of it: reserve shelf,
/// license, paywall.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// The kind of work, chosen to map onto BibTeX entry types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReferenceKind {
/// A whole book.
#[default]
Book,
/// A chapter in an edited volume.
Chapter,
/// A journal article.
Article,
/// A preprint, which is an article without a container.
Preprint,
/// A thesis or dissertation.
Thesis,
/// A page or resource that exists only online.
Website,
/// A program or library.
Software,
/// A published dataset.
Dataset,
/// A recording.
Video,
/// Anything else.
Other,
}
/// Whether the course requires a work or offers it as background.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReferenceRole {
/// A course text. Assigned readings come from it.
Required,
/// Listed so students know it exists. Never assigned.
#[default]
Supplemental,
}
/// One assigned location inside a [`Reference`], and what it is assigned for.
///
/// The prose splits three ways because each part answers a different question and
/// each has a different consumer. `summary` says what the section contains, `focus`
/// says what to take from it, and `skip` says what to ignore. A student report
/// quotes `focus` at somebody who missed the objective; a lecture page prints all
/// three.
///
/// A reading written as a bare string, which is what this field held before the
/// schema existed, still parses: the whole string lands in `text`, and serializing
/// writes it back out as a string rather than a mapping.
#[derive(Debug, Clone, Default)]
pub struct Reading {
/// Citation key into [`CourseFile::references`].
pub reference: Option<String>,
/// Where inside the work: `§6.1`, `pp. 212-219`, `ch. 3`, `fig. 4`.
pub locator: Option<String>,
/// Appended to the reference's `base_url` to reach this location.
pub path: Option<String>,
/// A full URL, for a location that is not under the reference's `base_url`.
pub url: Option<String>,
/// Whether it is assigned or offered alongside.
pub role: ReadingRole,
/// The objectives this reading serves.
pub objectives: Vec<String>,
/// What the section contains.
pub summary: Option<String>,
/// What to take from it, which is the sentence a study suggestion quotes.
pub focus: Option<String>,
/// What to gloss, and why it is out of scope.
pub skip: Option<String>,
/// A reading written as a bare string before this schema existed, held
/// unparsed.
pub text: Option<String>,
}
/// Whether a reading is assigned or offered alongside.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReadingRole {
/// Assigned, and therefore fair to assess.
#[default]
Assigned,
/// Offered as background. Not separately assessed.
Supplemental,
}
impl Reading {
/// The URL for this location.
///
/// # Arguments
///
/// * `reference` - the work this reading is inside.
///
/// # Returns
///
/// `url` when given, otherwise the reference's `base_url` joined with `path`,
/// otherwise `None`.
pub fn resolve_url(&self, reference: &Reference) -> Option<String> {
if let Some(url) = &self.url {
return Some(url.clone());
}
let path = self.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}"),
})
}
/// A short citation for a report: `KKW §6.1`.
///
/// # Arguments
///
/// * `key` - the citation key, used when the reference declares no label.
/// * `reference` - the work, for its label.
///
/// # Returns
///
/// The label and locator, or the unparsed `text` for a legacy reading.
pub fn cite(&self, key: &str, reference: &Reference) -> String {
if let Some(text) = &self.text {
return text.clone();
}
let label = reference.label.as_deref().unwrap_or(key);
match &self.locator {
Some(locator) => format!("{label} {locator}"),
None => label.to_string(),
}
}
}
/// Writes a reading as a mapping, or as a bare string when that is all it holds.
///
/// The string case keeps a course file that predates this schema byte-identical
/// through a load-and-save cycle, so migrating is something you choose rather than
/// something the tool does to your file the first time it writes it.
impl Serialize for Reading {
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.objectives.is_empty() {
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 self.role != ReadingRole::Assigned {
map.serialize_entry("role", &self.role)?;
}
if !self.objectives.is_empty() {
map.serialize_entry("objectives", &self.objectives)?;
}
if let Some(v) = &self.summary {
map.serialize_entry("summary", v)?;
}
if let Some(v) = &self.focus {
map.serialize_entry("focus", v)?;
}
if let Some(v) = &self.skip {
map.serialize_entry("skip", v)?;
}
if let Some(v) = &self.text {
map.serialize_entry("text", v)?;
}
map.end()
}
}
/// Accepts a reading written either as a mapping or as a bare string.
///
/// The string form is what `readings` held before this schema, so course files
/// written against the old shape keep loading. It is the same courtesy
/// [`yaml::flexible_string`] extends to an unquoted `schema_version: 1.0`.
impl<'de> Deserialize<'de> for Reading {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Reading, 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)]
role: ReadingRole,
#[serde(default)]
objectives: Vec<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
focus: Option<String>,
#[serde(default)]
skip: Option<String>,
#[serde(default)]
text: Option<String>,
}
struct V;
impl<'a> Visitor<'a> for V {
type Value = Reading;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a reading mapping with a `ref`, or a plain citation string")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Reading, E> {
Ok(Reading {
text: Some(v.to_string()),
..Reading::default()
})
}
fn visit_map<M: MapAccess<'a>>(self, map: M) -> std::result::Result<Reading, M::Error> {
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(Reading {
reference: m.reference,
locator: m.locator,
path: m.path,
url: m.url,
role: m.role,
objectives: m.objectives,
summary: m.summary,
focus: m.focus,
skip: m.skip,
text: m.text,
})
}
}
d.deserialize_any(V)
}
}
/// A learning objective.
@@ -190,6 +508,15 @@ pub struct Objective {
/// The lectures that develop it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Position in teaching order, low first.
///
/// The registry is a map, so declaration order is lost on load, and sorting by
/// id would put `lo-enthalpy` before `lo-first-law` when the second is a
/// prerequisite of the first. Anything that prints objectives in the order you
/// teach them, a lecture page above all, needs this. Objectives without it sort
/// last, by id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
/// The highest level you intend to assess this objective at. Assembling an
/// item above the ceiling is a warning: either the item overreaches or the
/// ceiling needs raising.
@@ -306,6 +633,25 @@ impl CourseFile {
}
}
let mut labels: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (key, reference) in &self.references {
if reference.title.trim().is_empty() {
issues.push(format!("reference `{key}`: empty title"));
}
if let Some(label) = &reference.label {
labels.entry(label.as_str()).or_default().push(key);
}
}
for (label, keys) in &labels {
if keys.len() > 1 {
issues.push(format!(
"references: `{label}` is the label of {}; a label has to name one work \
because reports print it instead of the key",
keys.join(" and ")
));
}
}
for (id, lec) in &self.lectures {
if lec.title.trim().is_empty() {
issues.push(format!("lecture `{id}`: empty title"));
@@ -315,6 +661,10 @@ impl CourseFile {
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
}
}
let mut seen: Vec<(&str, &str)> = Vec::new();
for (index, reading) in lec.readings.iter().enumerate() {
issues.extend(self.reading_issues(id, index, reading, &mut seen));
}
}
for (id, lo) in &self.learning_objectives {
@@ -347,6 +697,66 @@ impl CourseFile {
issues
}
/// Checks one reading, collecting every problem with it.
///
/// # Arguments
///
/// * `lecture` - the lecture id, for the message.
/// * `index` - position in the lecture's list, since a reading has no id.
/// * `reading` - the reading.
/// * `seen` - reference and locator pairs already found in this lecture,
/// extended as it goes.
///
/// # Returns
///
/// One message per problem.
fn reading_issues<'a>(
&self,
lecture: &str,
index: usize,
reading: &'a Reading,
seen: &mut Vec<(&'a str, &'a str)>,
) -> Vec<String> {
let mut issues = Vec::new();
let at = format!("lecture `{lecture}` reading {}", index + 1);
let Some(key) = reading.reference.as_deref() else {
if reading.text.is_none() {
issues.push(format!(
"{at}: needs a `ref` naming a reference, or a plain citation string"
));
}
return issues;
};
match self.references.get(key) {
None => issues.push(format!("{at}: unknown reference `{key}`")),
Some(reference) => {
if reading.path.is_some() && reference.base_url.is_none() && reading.url.is_none() {
issues.push(format!(
"{at}: has a `path` but reference `{key}` has no `base_url` to join it to"
));
}
}
}
if let Some(locator) = reading.locator.as_deref() {
if seen.contains(&(key, locator)) {
issues.push(format!(
"{at}: `{key} {locator}` is assigned twice in one lecture"
));
}
seen.push((key, locator));
}
for objective in &reading.objectives {
if !self.learning_objectives.contains_key(objective) {
issues.push(format!("{at}: unknown learning objective `{objective}`"));
}
}
issues
}
/// Detects cycles in the objective prerequisite graph.
///
/// A cycle would make a study-order suggestion loop forever, so it is worth
@@ -470,7 +880,8 @@ impl CourseFile {
.unwrap_or_else(|| id.to_string())
}
/// Objectives in a stable teaching order: by unit as declared, then by id.
/// Objectives in a stable teaching order: by unit as declared, then by
/// [`Objective::order`], then by id.
///
/// # Returns
///
@@ -490,11 +901,148 @@ impl CourseFile {
.as_deref()
.and_then(|u| unit_rank.get(u).copied())
.unwrap_or(usize::MAX);
(rank, (*id).clone())
(rank, lo.order.unwrap_or(u32::MAX), (*id).clone())
});
ids.into_iter().cloned().collect()
}
/// The objectives a lecture covers, in teaching order.
///
/// # Arguments
///
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// Objective ids whose `lectures` list names this lecture, ordered by
/// [`Objective::order`] and then by id.
pub fn lecture_objectives(&self, lecture: &str) -> Vec<&str> {
let mut ids: Vec<&String> = self
.learning_objectives
.iter()
.filter(|(_, lo)| lo.lectures.iter().any(|l| l == lecture))
.map(|(id, _)| id)
.collect();
ids.sort_by_key(|id| {
let lo = &self.learning_objectives[*id];
(lo.order.unwrap_or(u32::MAX), (*id).clone())
});
ids.into_iter().map(String::as_str).collect()
}
/// Every reading that serves an objective, with the lecture it was assigned in.
///
/// Derived by scanning lectures rather than stored on the objective, for the
/// same reason [`crate::history::History`] derives usage from assessment
/// records: a second copy of an edge is a second thing to keep in step. It also
/// puts the pointer on the volatile side, since a new edition renumbers
/// sections but leaves your objectives alone.
///
/// # Arguments
///
/// * `objective` - the objective id.
///
/// # Returns
///
/// Pairs of lecture id and reading, in lecture order then assignment order.
pub fn readings_for_objective(&self, objective: &str) -> Vec<(&str, &Reading)> {
let mut out = Vec::new();
for (lecture_id, lecture) in &self.lectures {
for reading in &lecture.readings {
if reading.objectives.iter().any(|o| o == objective) {
out.push((lecture_id.as_str(), reading));
}
}
}
out
}
/// Assessed objectives with no reading behind them.
///
/// These are the objectives a student report cannot advise on: it can say the
/// objective was missed, but not where to go and read about it.
///
/// # Returns
///
/// Objective ids in teaching order.
pub fn objectives_without_readings(&self) -> Vec<&str> {
let cited: std::collections::BTreeSet<&str> = self
.lectures
.values()
.flat_map(|l| l.readings.iter())
.flat_map(|r| r.objectives.iter())
.map(String::as_str)
.collect();
self.objectives_in_order()
.into_iter()
.filter_map(|id| {
let (key, lo) = self.learning_objectives.get_key_value(&id)?;
(lo.assessed && !cited.contains(key.as_str())).then_some(key.as_str())
})
.collect()
}
/// Looks up a reference, erroring on a dangling citation key.
///
/// # Arguments
///
/// * `key` - the citation key.
/// * `context` - what cited it, for the error message.
///
/// # Returns
///
/// The reference.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the key is not registered.
pub fn reference(&self, key: &str, context: &str) -> Result<&Reference> {
self.references.get(key).ok_or_else(|| Error::Unresolved {
kind: "reference",
id: key.to_string(),
context: Some(context.to_string()),
})
}
/// Expands `{objective-id}` in a prose field to whatever the caller wants.
///
/// Reading notes refer to objectives in passing ("a worked instance of
/// `{lo-vdw-additivity}`"), and a lecture page renders that as a number while a
/// student report renders it as text. Only a name that resolves to a declared
/// objective is treated as a placeholder, so `$U_\text{final}$` passes through
/// untouched; that collision is the reason this is not a general template
/// syntax.
///
/// # Arguments
///
/// * `prose` - the field to expand.
/// * `render` - called with each resolved objective id.
///
/// # Returns
///
/// The prose with resolved placeholders replaced.
pub fn expand_objective_refs(&self, prose: &str, render: impl Fn(&str) -> String) -> String {
let mut out = String::with_capacity(prose.len());
let mut rest = prose;
while let Some(open) = rest.find('{') {
let (head, tail) = rest.split_at(open);
out.push_str(head);
let Some(close) = tail.find('}') else {
out.push_str(tail);
return out;
};
let name = &tail[1..close];
if self.learning_objectives.contains_key(name) {
out.push_str(&render(name));
} else {
out.push_str(&tail[..=close]);
}
rest = &tail[close + 1..];
}
out.push_str(rest);
out
}
/// A skeleton course file for `coursebank init`.
///
/// # Arguments
@@ -525,6 +1073,7 @@ impl CourseFile {
text: "Replace this with an objective stated as a student action.".to_string(),
unit: Some("u-intro".to_string()),
lectures: vec!["L01".to_string()],
order: Some(1),
level_ceiling: Some(Level::Understand),
prerequisites: Vec::new(),
tags: Vec::new(),
@@ -549,6 +1098,7 @@ impl CourseFile {
}],
lectures,
learning_objectives: los,
references: BTreeMap::new(),
stimuli: BTreeMap::new(),
}
}
@@ -706,4 +1256,215 @@ learning_objectives:
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
assert_eq!(slugify(" "), "");
}
/// A course with one reference and two readings, one of them supplemental.
fn with_readings() -> CourseFile {
parse(
r#"
course: { code: X, title: Y, term: Z }
references:
kuriyan2013molecules:
label: KKW
role: required
title: The molecules of life
base_url: https://example.org/kkw/
lectures:
L1.1:
title: Enthalpy
readings:
- ref: kuriyan2013molecules
locator: '§6.1'
path: '6/A/#1'
objectives: [lo-a]
summary: What a system is.
focus: Fix the definitions.
- ref: kuriyan2013molecules
locator: '§1.9'
path: '1/B/#9'
role: supplemental
objectives: [lo-b]
learning_objectives:
lo-a: { text: A, lectures: [L1.1], order: 1 }
lo-b: { text: B, lectures: [L1.1], order: 2 }
"#,
)
}
#[test]
fn a_structured_reading_parses_and_validates() {
let c = with_readings();
assert!(c.validate().is_empty(), "{:?}", c.validate());
let readings = &c.lectures["L1.1"].readings;
assert_eq!(
readings[0].reference.as_deref(),
Some("kuriyan2013molecules")
);
assert_eq!(readings[0].role, ReadingRole::Assigned);
assert_eq!(readings[1].role, ReadingRole::Supplemental);
}
#[test]
fn a_reading_url_is_built_from_the_reference_base() {
let c = with_readings();
let reference = &c.references["kuriyan2013molecules"];
let reading = &c.lectures["L1.1"].readings[0];
assert_eq!(
reading.resolve_url(reference).as_deref(),
Some("https://example.org/kkw/6/A/#1")
);
assert_eq!(reading.cite("kuriyan2013molecules", reference), "KKW §6.1");
}
#[test]
fn a_bare_string_reading_still_parses_and_round_trips() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
lectures:
L01:
title: One
readings:
- 'KKW §6.1: system and surroundings. https://example.org/1'
"#,
);
let reading = &c.lectures["L01"].readings[0];
assert!(reading.reference.is_none());
assert_eq!(
reading.text.as_deref(),
Some("KKW §6.1: system and surroundings. https://example.org/1")
);
assert!(c.validate().is_empty());
// Serializing writes the string back as a string, so a load-and-save cycle
// does not migrate a file the author has not chosen to migrate.
let yaml = serde_yaml_ng::to_string(&c).expect("serializes");
assert!(yaml.contains("- 'KKW §6.1: system and surroundings. https://example.org/1'"));
assert!(!yaml.contains("text:"));
}
#[test]
fn readings_resolve_backwards_from_an_objective() {
let c = with_readings();
let found = c.readings_for_objective("lo-a");
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "L1.1");
assert_eq!(found[0].1.locator.as_deref(), Some("§6.1"));
assert!(c.readings_for_objective("lo-nobody").is_empty());
}
#[test]
fn an_objective_with_no_reading_is_reported() {
let mut c = with_readings();
assert!(c.objectives_without_readings().is_empty());
c.learning_objectives.insert(
"lo-orphan".to_string(),
Objective {
text: "Orphan".to_string(),
unit: None,
lectures: vec!["L1.1".to_string()],
order: Some(3),
level_ceiling: None,
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
},
);
assert_eq!(c.objectives_without_readings(), vec!["lo-orphan"]);
// An objective you teach but do not test is not a gap.
c.learning_objectives
.get_mut("lo-orphan")
.expect("just inserted")
.assessed = false;
assert!(c.objectives_without_readings().is_empty());
}
#[test]
fn objective_order_beats_id_order_within_a_lecture() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
lectures:
L1.1: { title: One }
learning_objectives:
lo-enthalpy: { text: Third, lectures: [L1.1], order: 3 }
lo-first-law: { text: Second, lectures: [L1.1], order: 2 }
lo-system: { text: First, lectures: [L1.1], order: 1 }
"#,
);
// Alphabetically this is enthalpy, first-law, system, which puts an
// objective ahead of its own prerequisite.
assert_eq!(
c.lecture_objectives("L1.1"),
vec!["lo-system", "lo-first-law", "lo-enthalpy"]
);
}
#[test]
fn unknown_references_and_objectives_on_a_reading_are_reported() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
references:
known: { title: A book }
lectures:
L01:
title: One
readings:
- { ref: missing, locator: '§1' }
- { ref: known, locator: '§2', path: '2/', objectives: [lo-nope] }
"#,
);
let issues = c.validate();
assert!(
issues
.iter()
.any(|i| i.contains("unknown reference `missing`"))
);
assert!(
issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-nope`"))
);
// `path` with no base_url to join it to.
assert!(issues.iter().any(|i| i.contains("base_url")));
}
#[test]
fn a_duplicate_label_and_a_duplicate_locator_are_reported() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
references:
one: { title: First, label: KKW }
two: { title: Second, label: KKW }
lectures:
L01:
title: One
readings:
- { ref: one, locator: '§1' }
- { ref: one, locator: '§1' }
"#,
);
let issues = c.validate();
assert!(issues.iter().any(|i| i.contains("`KKW` is the label of")));
assert!(
issues
.iter()
.any(|i| i.contains("assigned twice in one lecture"))
);
}
#[test]
fn only_a_declared_objective_id_is_a_placeholder() {
let c = with_readings();
let expanded = c.expand_objective_refs(
r"a worked instance of {lo-a}, where $U_\text{final}$ is unchanged, {lo-typo} too",
|id| format!("<{id}>"),
);
assert_eq!(
expanded,
r"a worked instance of <lo-a>, where $U_\text{final}$ is unchanged, {lo-typo} too"
);
}
}
+312 -1
View File
@@ -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:"));
}
}
+50
View File
@@ -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);
}
}
}
+45 -2
View File
@@ -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
@@ -173,7 +199,7 @@ pub fn escape_html(s: &str) -> String {
/// # Returns
///
/// The substituted text.
fn apply_symbols(s: &str, html: bool) -> String {
pub(crate) fn apply_symbols(s: &str, html: bool) -> String {
let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS {
if out.contains(token) {
@@ -192,7 +218,7 @@ fn apply_symbols(s: &str, html: bool) -> String {
/// # Returns
///
/// The text with inline markup converted.
fn apply_inline(s: &str) -> String {
pub(crate) fn apply_inline(s: &str) -> String {
let mut out = s.to_string();
// Bracketed forms first: their contents may contain other markup characters.
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
@@ -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");
}
}