// 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)] } } ], ) } ] }