177 lines
6.1 KiB
Typst
177 lines
6.1 KiB
Typst
// coursebank — answer key template
|
|
//
|
|
// This one uses the `data` slot rather than `questions`: it gets the whole payload
|
|
// as a single dictionary and loops over it here, which suits a table better than an
|
|
// unrolled sequence of calls. Both slots are always available; use whichever fits
|
|
// what you are building.
|
|
//
|
|
// // coursebank:begin data
|
|
// // coursebank:end data
|
|
//
|
|
// The key's render config sets `reveal: everything`, so options here carry
|
|
// `correct`, `credit`, and the authored rationale. That is the opposite of the
|
|
// paper's config, and it is why these are two templates rather than one with a
|
|
// flag.
|
|
|
|
#import "@preview/mitex:0.2.7": mi
|
|
|
|
// coursebank:begin data
|
|
#let cb-data = (
|
|
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
|
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
|
|
form: (id: "A", count: 1),
|
|
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
|
|
dropped: (),
|
|
extra: (:),
|
|
questions: (
|
|
(
|
|
number: 1,
|
|
points: 1.5,
|
|
bonus: false,
|
|
level: 2,
|
|
level-name: "Understand",
|
|
stem: [This sample question is replaced on the next export.],
|
|
key: ("B",),
|
|
options: (
|
|
(letter: "A", position: 1, text: [Wrong.], correct: false, credit: 0.0),
|
|
(letter: "B", position: 2, text: [Right.], correct: true, credit: 1.0),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
// coursebank:end data
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Settings
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Anything under `extra` in templates/typst.yaml arrives here untouched, which is
|
|
// how a course changes the look without editing this file at all.
|
|
#let extra = cb-data.at("extra", default: (:))
|
|
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
|
|
#let body-font = extra.at("font", default: "Roboto")
|
|
#let body-size = eval(extra.at("font-size", default: "11pt"))
|
|
#let paper = extra.at("paper", default: "us-letter")
|
|
#let show-name-block = extra.at("name-block", default: true)
|
|
#let show-points = extra.at("show-points", default: true)
|
|
#let page-per-item = extra.at("page-per-item", default: false)
|
|
#let show-bubbles = extra.at("show-bubbles", default: true)
|
|
#let bubble-radius = eval(extra.at("bubble-radius", default: "0.5em"))
|
|
#let scratch-space = eval(extra.at("scratch-space", default: "1.5em"))
|
|
|
|
#let extra = cb-data.at("extra", default: (:))
|
|
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
|
|
|
|
#set page(paper: extra.at("paper", default: "us-letter"), margin: 2cm)
|
|
#set text(font: body-font, size: body-size, lang: "en")
|
|
|
|
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
|
|
#let fmt-points(p) = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) }
|
|
|
|
#let objectives-of(q) = q.at("learning-objectives", default: ()).join(", ")
|
|
|
|
= #cb-data.assessment.title — answer key (form #cb-data.form.id)
|
|
|
|
#text(size: 0.9em, style: "italic")[
|
|
Letters below are the letters *as printed on this form*. Do not use this key on
|
|
another form. Form seed #cb-data.form.at("seed", default: "—").
|
|
]
|
|
|
|
#v(0.5em)
|
|
|
|
#table(
|
|
columns: (auto, auto, auto, auto, 1fr),
|
|
align: (right, center, center, right, left),
|
|
table.header([*\#*], [*Key*], [*Level*], [*Pts*], [*Objectives*]),
|
|
..cb-data
|
|
.questions
|
|
.map(q => (
|
|
[#q.number],
|
|
[*#q.at("key", default: ()).join("")*],
|
|
[#str(q.at("level", default: "—"))],
|
|
[#fmt-points(q.at("points", default: 0))],
|
|
[#objectives-of(q)],
|
|
))
|
|
.flatten(),
|
|
)
|
|
|
|
// ── Partial credit ──
|
|
// Recorded against the letters the students actually saw, so a grader can apply it
|
|
// without remapping anything.
|
|
#{
|
|
let rows = cb-data.questions.filter(q => q.at("credit-overrides", default: (:)).len() > 0)
|
|
if rows.len() > 0 {
|
|
heading(level: 2)[Partial credit]
|
|
list(
|
|
..rows.map(q => {
|
|
let parts = q
|
|
.at("credit-overrides", default: (:))
|
|
.pairs()
|
|
.map(pair => pair.at(0) + " = " + str(int(calc.round(pair.at(1) * 100))) + "%")
|
|
[Question #q.number: #parts.join(", ")]
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Dropped ──
|
|
#{
|
|
let dropped = cb-data.at("dropped", default: ())
|
|
if dropped.len() > 0 {
|
|
heading(level: 2)[Dropped]
|
|
[
|
|
Question(s) #dropped.map(str).join(", ") were dropped after administration
|
|
and are not printed.
|
|
]
|
|
}
|
|
}
|
|
|
|
// ── Rationale ──
|
|
// Everything below exists because a key you can grade from is not the same
|
|
// document as a key you can defend a challenge with.
|
|
#pagebreak(weak: true)
|
|
|
|
#heading(level: 2)[Rationale]
|
|
|
|
#for q in cb-data.questions {
|
|
block(breakable: false, above: 1.2em)[
|
|
#text(weight: "bold")[Question #q.number]
|
|
#{
|
|
let uid = q.at("uid", default: none)
|
|
if uid != none { text(size: 0.85em, fill: luma(120))[ · #uid] }
|
|
}
|
|
|
|
#markup(q.stem)
|
|
|
|
#for opt in q.at("options", default: ()) {
|
|
let correct = opt.at("correct", default: false)
|
|
let credit = opt.at("credit", default: 0.0)
|
|
// Drawn rather than typed: a check-mark glyph is not in every font, and a
|
|
// missing glyph on an answer key is a box where the answer should be.
|
|
let marker = if correct {
|
|
box(width: 0.55em, height: 0.55em, radius: 1pt, fill: accent)
|
|
} else if credit > 0 {
|
|
box(width: 0.55em, height: 0.55em, radius: 1pt, stroke: 0.6pt + accent)
|
|
} else {
|
|
box(width: 0.55em, height: 0.55em)
|
|
}
|
|
grid(
|
|
columns: (1.2em, 1.4em, 1fr),
|
|
gutter: 0.3em,
|
|
[#marker],
|
|
[#(opt.letter + ".")],
|
|
[
|
|
#markup(opt.text)
|
|
#{
|
|
let why = opt.at("explanation", default: opt.at("misconception", default: none))
|
|
if why != none {
|
|
linebreak()
|
|
text(size: 0.85em, fill: luma(90), style: "italic")[#markup(why)]
|
|
}
|
|
}
|
|
],
|
|
)
|
|
}
|
|
]
|
|
}
|