feat: implement reports
This commit is contained in:
@@ -43,11 +43,25 @@ pub enum Variant {
|
||||
Key,
|
||||
/// A bubble sheet matching the form.
|
||||
AnswerSheet,
|
||||
/// One student's diagnostic, which carries no questions.
|
||||
StudentReport,
|
||||
/// The instructor's class diagnostic.
|
||||
CohortReport,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// Every variant, in the order `export` writes them.
|
||||
pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
|
||||
pub const ALL: [Variant; 5] = [
|
||||
Variant::Exam,
|
||||
Variant::Key,
|
||||
Variant::AnswerSheet,
|
||||
Variant::StudentReport,
|
||||
Variant::CohortReport,
|
||||
];
|
||||
|
||||
/// The variants `export typst` produces. A report is not built from an
|
||||
/// assessment record alone, so `export typst` must not default to it.
|
||||
pub const EXAM: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
|
||||
|
||||
/// The token used on the command line, in config keys, and in file names.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
@@ -55,6 +69,8 @@ impl Variant {
|
||||
Variant::Exam => "exam",
|
||||
Variant::Key => "key",
|
||||
Variant::AnswerSheet => "answer-sheet",
|
||||
Variant::StudentReport => "student-report",
|
||||
Variant::CohortReport => "cohort-report",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +119,8 @@ impl Variant {
|
||||
Variant::Exam => "",
|
||||
Variant::Key => "-key",
|
||||
Variant::AnswerSheet => "-answer-sheet",
|
||||
Variant::StudentReport => "-student",
|
||||
Variant::CohortReport => "-cohort",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,6 +440,30 @@ impl RenderConfig {
|
||||
calibration: false,
|
||||
};
|
||||
}
|
||||
Variant::StudentReport => {
|
||||
// Belt and braces. The student payload is built by
|
||||
// `typst::diagnostic`, which has no question text to reveal in the
|
||||
// first place; this says so in the one place someone would look.
|
||||
config.reveal = Reveal::Nothing;
|
||||
config.stimulus = StimulusMode::Omit;
|
||||
config.fields = Fields {
|
||||
uid: false,
|
||||
title: false,
|
||||
points: true,
|
||||
level: true,
|
||||
objectives: true,
|
||||
topics: false,
|
||||
assets: false,
|
||||
source_letters: false,
|
||||
design: false,
|
||||
calibration: false,
|
||||
};
|
||||
}
|
||||
Variant::CohortReport => {
|
||||
config.reveal = Reveal::Everything;
|
||||
config.stimulus = StimulusMode::Omit;
|
||||
config.fields.calibration = true;
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Handing a diagnostic to a Typst template.
|
||||
//!
|
||||
//! The same arrangement the exam export uses: this module decides what the
|
||||
//! template is told, the template decides what it looks like, and the two meet at
|
||||
//! a marker comment. Nothing here knows about page size or colour.
|
||||
//!
|
||||
//! Two slots are filled, both already defined for the exam path:
|
||||
//!
|
||||
//! | Slot | Injected |
|
||||
//! |:--|:--|
|
||||
//! | `meta` | `#let cb-meta = (...)` — course, assessment, generator, class size |
|
||||
//! | `data` | `#let cb-data = (...)` — one student's diagnostic, or the class's |
|
||||
//!
|
||||
//! The `questions` slot is deliberately unused. It exists to emit question stems,
|
||||
//! and a diagnostic has none to emit: see
|
||||
//! [`crate::diagnostic::StudentDiagnostic`], which has no field for one. A student
|
||||
//! report template that wanted to print a stem would have nothing to print it
|
||||
//! from, which is the property worth preserving.
|
||||
//!
|
||||
//! Prose fields — objective text, the feedback written for a chosen option, a
|
||||
//! reading's focus sentence — go through the same markup path as an exam stem, so
|
||||
//! `$\Delta G$` in `course.yaml` renders the same way in a report as it does on
|
||||
//! the paper.
|
||||
|
||||
use crate::assessment::AssessmentFile;
|
||||
use crate::catalog::Catalog;
|
||||
use crate::diagnostic::{
|
||||
Bin, CohortDiagnostic, CohortObjectiveRow, CohortQuestionRow, StudentDiagnostic, StudyGroup,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::layout::Layout;
|
||||
use crate::typst::config::{RenderConfig, Variant};
|
||||
use crate::typst::payload::markup_value;
|
||||
use crate::typst::template::{self, Origin, Slot};
|
||||
use crate::typst::value::Value;
|
||||
|
||||
/// A rendered diagnostic document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Document {
|
||||
/// Which document this is.
|
||||
pub variant: Variant,
|
||||
/// Where the template came from.
|
||||
pub origin: Origin,
|
||||
/// Which slots it declared.
|
||||
pub slots: Vec<Slot>,
|
||||
/// The Typst source.
|
||||
pub text: String,
|
||||
/// Advisory problems.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The identity block every diagnostic carries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Meta {
|
||||
/// Course code.
|
||||
pub course_code: String,
|
||||
/// Course title.
|
||||
pub course_title: String,
|
||||
/// The term.
|
||||
pub term: String,
|
||||
/// Institution, when the course names one.
|
||||
pub institution: Option<String>,
|
||||
/// Assessment id.
|
||||
pub assessment_id: String,
|
||||
/// Assessment title.
|
||||
pub assessment_title: String,
|
||||
/// The administration date, as `YYYY-MM-DD`.
|
||||
pub date: Option<String>,
|
||||
/// The day the report was generated.
|
||||
pub generated_on: String,
|
||||
/// The tool version, so a report found later can be traced.
|
||||
pub version: String,
|
||||
/// How many students sat the assessment.
|
||||
pub n_students: usize,
|
||||
/// The course's mastery threshold, so a template can draw the line in the
|
||||
/// same place the classification used.
|
||||
pub mastery_threshold: f64,
|
||||
/// How many items an objective needs before it is classified at all.
|
||||
pub min_items_for_mastery: usize,
|
||||
}
|
||||
|
||||
impl Meta {
|
||||
/// Builds the identity block.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `n_students` - the cohort size.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The block.
|
||||
pub fn new(catalog: &Catalog, record: &AssessmentFile, n_students: usize) -> Meta {
|
||||
Meta {
|
||||
course_code: catalog.course.course.code.clone(),
|
||||
course_title: catalog.course.course.title.clone(),
|
||||
term: record
|
||||
.assessment
|
||||
.term
|
||||
.clone()
|
||||
.unwrap_or_else(|| catalog.course.course.term.clone()),
|
||||
institution: catalog.course.course.institution.clone(),
|
||||
assessment_id: record.assessment.id.clone(),
|
||||
assessment_title: record.assessment.title.clone(),
|
||||
date: record.assessment.date.map(|d| d.to_string()),
|
||||
generated_on: crate::date::Date::today().to_string(),
|
||||
version: crate::VERSION.to_string(),
|
||||
n_students,
|
||||
mastery_threshold: catalog.course.policy.mastery_threshold,
|
||||
min_items_for_mastery: catalog.course.policy.min_items_for_mastery,
|
||||
}
|
||||
}
|
||||
|
||||
/// The metadata as a Typst value.
|
||||
fn value(&self, config: &RenderConfig) -> Value {
|
||||
let mut course = Value::dict();
|
||||
course.insert("code", Value::str(&self.course_code));
|
||||
course.insert("title", Value::str(&self.course_title));
|
||||
course.insert("term", Value::str(&self.term));
|
||||
if let Some(institution) = &self.institution {
|
||||
course.insert("institution", Value::str(institution));
|
||||
}
|
||||
|
||||
let mut assessment = Value::dict();
|
||||
assessment.insert("id", Value::str(&self.assessment_id));
|
||||
assessment.insert("title", Value::str(&self.assessment_title));
|
||||
assessment.insert_some("date", self.date.as_ref().map(Value::str));
|
||||
|
||||
let mut generator = Value::dict();
|
||||
generator.insert("tool", Value::str("coursebank"));
|
||||
generator.insert("version", Value::str(&self.version));
|
||||
generator.insert("on", Value::str(&self.generated_on));
|
||||
|
||||
let mut policy = Value::dict();
|
||||
policy.insert("mastery-threshold", Value::Float(self.mastery_threshold));
|
||||
policy.insert(
|
||||
"min-items-for-mastery",
|
||||
Value::Int(self.min_items_for_mastery as i64),
|
||||
);
|
||||
|
||||
let mut out = Value::dict();
|
||||
out.insert("course", course);
|
||||
out.insert("assessment", assessment);
|
||||
out.insert("generator", generator);
|
||||
out.insert("policy", policy);
|
||||
out.insert("class-size", Value::Int(self.n_students as i64));
|
||||
out.insert("extra", extra_value(config));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The render config's `extra` block, carried through untouched.
|
||||
fn extra_value(config: &RenderConfig) -> Value {
|
||||
let mut out = Value::dict();
|
||||
for (key, value) in &config.extra {
|
||||
out.insert(key.clone(), crate::typst::value::from_yaml(value, false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One student's diagnostic as a Typst value.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `diagnostic` - the assembled diagnostic.
|
||||
/// * `config` - the render config, for markup handling.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A dictionary the template binds as `cb-data`.
|
||||
pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> Value {
|
||||
let content = config.content.is_content();
|
||||
let mut out = Value::dict();
|
||||
|
||||
out.insert("student-key", Value::str(&diagnostic.student_key));
|
||||
out.insert_some("name", diagnostic.name.as_ref().map(Value::str));
|
||||
out.insert_some("sid", diagnostic.sid.as_ref().map(Value::str));
|
||||
out.insert_some("form", diagnostic.form.as_ref().map(Value::str));
|
||||
|
||||
let mut score = Value::dict();
|
||||
score.insert("points", Value::Float(diagnostic.score.points));
|
||||
score.insert("possible", Value::Float(diagnostic.score.points_possible));
|
||||
score.insert("percent", Value::Float(diagnostic.score.percent));
|
||||
score.insert("bonus", Value::Float(diagnostic.score.bonus_points));
|
||||
score.insert("correct", Value::Int(diagnostic.score.correct as i64));
|
||||
score.insert("items", Value::Int(diagnostic.score.n_items as i64));
|
||||
out.insert("score", score);
|
||||
|
||||
if let Some(standing) = &diagnostic.standing {
|
||||
let mut value = Value::dict();
|
||||
value.insert("class-mean", Value::Float(standing.class_mean));
|
||||
value.insert("class-sd", Value::Float(standing.class_sd));
|
||||
value.insert("band", Value::str(&standing.band));
|
||||
value.insert_some("theta", standing.theta.map(Value::Float));
|
||||
value.insert_some("theta-se", standing.theta_se.map(Value::Float));
|
||||
out.insert("standing", value);
|
||||
}
|
||||
|
||||
out.insert(
|
||||
"levels",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.levels
|
||||
.iter()
|
||||
.map(|level| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("level", Value::Int(level.level as i64));
|
||||
value.insert("name", Value::str(&level.name));
|
||||
value.insert("blurb", Value::str(&level.blurb));
|
||||
value.insert("items", Value::Int(level.n_items as i64));
|
||||
value.insert("rate", Value::Float(level.rate));
|
||||
value.insert_some("class-rate", level.class_rate.map(Value::Float));
|
||||
value.insert_some("comparison", level.comparison.as_ref().map(Value::str));
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"objectives",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.objectives
|
||||
.iter()
|
||||
.map(|objective| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("id", Value::str(&objective.id));
|
||||
value.insert("text", markup_value(&objective.text, content));
|
||||
value.insert_some("unit", objective.unit.as_ref().map(Value::str));
|
||||
value.insert("items", Value::Int(objective.n_items as i64));
|
||||
value.insert("credit", Value::Float(objective.credit));
|
||||
value.insert("rate", Value::Float(objective.rate));
|
||||
value.insert("lower", Value::Float(objective.lower));
|
||||
value.insert("upper", Value::Float(objective.upper));
|
||||
value.insert_some("class-rate", objective.class_rate.map(Value::Float));
|
||||
value.insert("status", Value::str(&objective.status));
|
||||
value.insert("symbol", Value::str(&objective.symbol));
|
||||
value.insert("confident", Value::Bool(objective.confident));
|
||||
value.insert("thin-evidence", Value::Bool(objective.thin_evidence));
|
||||
value.insert(
|
||||
"levels",
|
||||
Value::Array(
|
||||
objective
|
||||
.levels
|
||||
.iter()
|
||||
.map(|l| Value::Int(*l as i64))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
let objective_refs = |rows: &[crate::diagnostic::ObjectiveRef]| -> Value {
|
||||
Value::Array(
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("id", Value::str(&row.id));
|
||||
value.insert("text", markup_value(&row.text, content));
|
||||
value.insert("rate", Value::Float(row.rate));
|
||||
value.insert("items", Value::Int(row.n_items as i64));
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
out.insert("strengths", objective_refs(&diagnostic.strengths));
|
||||
out.insert("focus", objective_refs(&diagnostic.focus));
|
||||
|
||||
out.insert(
|
||||
"questions",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.questions
|
||||
.iter()
|
||||
.map(|question| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("number", Value::Int(question.number as i64));
|
||||
value.insert_some("position", question.position.map(|p| Value::Int(p as i64)));
|
||||
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
|
||||
value.insert(
|
||||
"objectives",
|
||||
Value::Array(question.objectives.iter().map(|o| Value::str(o)).collect()),
|
||||
);
|
||||
value.insert_some("correct", question.correct.map(Value::Bool));
|
||||
value.insert("credit", Value::Float(question.credit));
|
||||
value.insert("bonus", Value::Bool(question.bonus));
|
||||
value.insert("blank", Value::Bool(question.blank));
|
||||
value.insert_some("class-rate", question.class_rate.map(Value::Float));
|
||||
value.insert_some(
|
||||
"feedback",
|
||||
question
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(|text| markup_value(text, content)),
|
||||
);
|
||||
value.insert(
|
||||
"taught-in",
|
||||
Value::Array(question.taught_in.iter().map(|s| Value::str(s)).collect()),
|
||||
);
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"study",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.study
|
||||
.iter()
|
||||
.map(|group| study_value(group, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// One study group as a Typst value.
|
||||
fn study_value(group: &StudyGroup, content: bool) -> Value {
|
||||
let mut out = Value::dict();
|
||||
out.insert("objective", Value::str(&group.objective));
|
||||
out.insert("text", markup_value(&group.text, content));
|
||||
out.insert("rate", Value::Float(group.rate));
|
||||
out.insert(
|
||||
"readings",
|
||||
Value::Array(
|
||||
group
|
||||
.readings
|
||||
.iter()
|
||||
.map(|reading| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("citation", Value::str(&reading.citation));
|
||||
value.insert("lecture", Value::str(&reading.lecture));
|
||||
value.insert("lecture-title", Value::str(&reading.lecture_title));
|
||||
value.insert_some("url", reading.url.as_ref().map(Value::str));
|
||||
value.insert_some(
|
||||
"focus",
|
||||
reading.focus.as_ref().map(|t| markup_value(t, content)),
|
||||
);
|
||||
value.insert_some(
|
||||
"summary",
|
||||
reading.summary.as_ref().map(|t| markup_value(t, content)),
|
||||
);
|
||||
value.insert("supplemental", Value::Bool(reading.supplemental));
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// The class diagnostic as a Typst value.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `diagnostic` - the assembled diagnostic.
|
||||
/// * `config` - the render config, for markup handling.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A dictionary the template binds as `cb-data`.
|
||||
pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Value {
|
||||
let content = config.content.is_content();
|
||||
let mut out = Value::dict();
|
||||
|
||||
out.insert("students", Value::Int(diagnostic.n_students as i64));
|
||||
out.insert("items", Value::Int(diagnostic.n_items as i64));
|
||||
|
||||
let mut distribution = Value::dict();
|
||||
distribution.insert("mean", Value::Float(diagnostic.distribution.mean));
|
||||
distribution.insert("median", Value::Float(diagnostic.distribution.median));
|
||||
distribution.insert("sd", Value::Float(diagnostic.distribution.sd));
|
||||
distribution.insert("min", Value::Float(diagnostic.distribution.min));
|
||||
distribution.insert("max", Value::Float(diagnostic.distribution.max));
|
||||
distribution.insert(
|
||||
"bins",
|
||||
Value::Array(diagnostic.distribution.bins.iter().map(bin_value).collect()),
|
||||
);
|
||||
out.insert("distribution", distribution);
|
||||
|
||||
let mut reliability = Value::dict();
|
||||
reliability.insert_some("alpha", diagnostic.reliability.alpha.map(Value::Float));
|
||||
reliability.insert_some("sem", diagnostic.reliability.sem.map(Value::Float));
|
||||
reliability.insert("mean-p", Value::Float(diagnostic.reliability.mean_p));
|
||||
reliability.insert_some(
|
||||
"mean-point-biserial",
|
||||
diagnostic.reliability.mean_point_biserial.map(Value::Float),
|
||||
);
|
||||
reliability.insert(
|
||||
"interpretation",
|
||||
Value::str(&diagnostic.reliability.interpretation),
|
||||
);
|
||||
out.insert("reliability", reliability);
|
||||
|
||||
out.insert(
|
||||
"levels",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.levels
|
||||
.iter()
|
||||
.map(|level| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("level", Value::Int(level.level as i64));
|
||||
value.insert("name", Value::str(&level.name));
|
||||
value.insert("items", Value::Int(level.n_items as i64));
|
||||
value.insert("rate", Value::Float(level.rate));
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"objectives",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.objectives
|
||||
.iter()
|
||||
.map(|o| cohort_objective_value(o, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
out.insert(
|
||||
"gaps",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.gaps
|
||||
.iter()
|
||||
.map(|o| cohort_objective_value(o, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"questions",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.questions
|
||||
.iter()
|
||||
.map(|q| cohort_question_value(q, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
out.insert(
|
||||
"revise",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.revise
|
||||
.iter()
|
||||
.map(|q| cohort_question_value(q, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"forms",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.forms
|
||||
.iter()
|
||||
.map(|form| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("id", Value::str(&form.id));
|
||||
value.insert("students", Value::Int(form.n_students as i64));
|
||||
value.insert("mean", Value::Float(form.mean));
|
||||
value.insert("sd", Value::Float(form.sd));
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"blueprint",
|
||||
Value::Array(diagnostic.blueprint.iter().map(|s| Value::str(s)).collect()),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"patterns",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.patterns
|
||||
.iter()
|
||||
.map(|pattern| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("label", Value::str(&pattern.label));
|
||||
value.insert("students", Value::Int(pattern.n_students as i64));
|
||||
let mut means = Value::dict();
|
||||
for (level, mean) in &pattern.level_means {
|
||||
means.insert(format!("l{level}"), Value::Float(*mean));
|
||||
}
|
||||
value.insert("level-means", means);
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"warnings",
|
||||
Value::Array(diagnostic.warnings.iter().map(|s| Value::str(s)).collect()),
|
||||
);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// One histogram bin as a Typst value.
|
||||
fn bin_value(bin: &Bin) -> Value {
|
||||
let mut value = Value::dict();
|
||||
value.insert("low", Value::Int(bin.low as i64));
|
||||
value.insert("high", Value::Int(bin.high as i64));
|
||||
value.insert("count", Value::Int(bin.count as i64));
|
||||
value
|
||||
}
|
||||
|
||||
/// One class objective row as a Typst value.
|
||||
fn cohort_objective_value(objective: &CohortObjectiveRow, content: bool) -> Value {
|
||||
let mut value = Value::dict();
|
||||
value.insert("id", Value::str(&objective.id));
|
||||
value.insert("text", markup_value(&objective.text, content));
|
||||
value.insert("items", Value::Int(objective.n_items as i64));
|
||||
value.insert("rate", Value::Float(objective.rate));
|
||||
value.insert("meeting", Value::Int(objective.meeting as i64));
|
||||
value.insert("developing", Value::Int(objective.developing as i64));
|
||||
value.insert("not-yet", Value::Int(objective.not_yet as i64));
|
||||
value.insert("thin", Value::Int(objective.thin as i64));
|
||||
value.insert("below-threshold", Value::Bool(objective.below_threshold));
|
||||
value
|
||||
}
|
||||
|
||||
/// One class question row as a Typst value.
|
||||
fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
|
||||
let mut value = Value::dict();
|
||||
value.insert("number", Value::Int(question.number as i64));
|
||||
value.insert_some("item", question.item.as_ref().map(Value::str));
|
||||
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
|
||||
value.insert(
|
||||
"objectives",
|
||||
Value::Array(question.objectives.iter().map(|o| Value::str(o)).collect()),
|
||||
);
|
||||
value.insert("p", Value::Float(question.p_value));
|
||||
value.insert_some("point-biserial", question.point_biserial.map(Value::Float));
|
||||
value.insert_some("discrimination", question.discrimination.map(Value::Float));
|
||||
value.insert("blank-rate", Value::Float(question.blank_rate));
|
||||
value.insert(
|
||||
"key",
|
||||
Value::Array(question.key.iter().map(|k| Value::str(k)).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"options",
|
||||
Value::Array(
|
||||
question
|
||||
.options
|
||||
.iter()
|
||||
.map(|option| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("letter", Value::str(&option.letter));
|
||||
value.insert("count", Value::Int(option.count as i64));
|
||||
value.insert("rate", Value::Float(option.rate));
|
||||
value.insert("is-key", Value::Bool(option.is_key));
|
||||
value.insert_some("point-biserial", option.point_biserial.map(Value::Float));
|
||||
value.insert("nonfunctioning", Value::Bool(option.nonfunctioning));
|
||||
value
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
value.insert(
|
||||
"flags",
|
||||
Value::Array(question.flags.iter().map(|f| Value::str(f)).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"notes",
|
||||
Value::Array(
|
||||
question
|
||||
.notes
|
||||
.iter()
|
||||
.map(|n| markup_value(n, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
let mut by_form = Value::dict();
|
||||
for (form, p) in &question.by_form {
|
||||
by_form.insert(form.clone(), Value::Float(*p));
|
||||
}
|
||||
value.insert("by-form", by_form);
|
||||
value
|
||||
}
|
||||
|
||||
/// Emits a `#let` binding for a slot.
|
||||
fn binding(name: &str, value: &Value) -> String {
|
||||
format!("#let {name} = {}\n", value.to_typst(0))
|
||||
}
|
||||
|
||||
/// Renders one student's report.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout, for the template lookup.
|
||||
/// * `meta` - the identity block.
|
||||
/// * `diagnostic` - the student's diagnostic.
|
||||
/// * `config` - the render config for this variant.
|
||||
/// * `explicit` - a template path overriding the lookup.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The rendered document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] when an explicit template cannot be read and
|
||||
/// [`Error::Invalid`] when a template's markers are malformed.
|
||||
pub fn render_student(
|
||||
layout: &Layout,
|
||||
meta: &Meta,
|
||||
diagnostic: &StudentDiagnostic,
|
||||
config: &RenderConfig,
|
||||
explicit: Option<&std::path::Path>,
|
||||
) -> Result<Document> {
|
||||
render(
|
||||
layout,
|
||||
Variant::StudentReport,
|
||||
&meta.assessment_id,
|
||||
meta,
|
||||
student_value(diagnostic, config),
|
||||
config,
|
||||
explicit,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the class report.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout, for the template lookup.
|
||||
/// * `meta` - the identity block.
|
||||
/// * `diagnostic` - the class diagnostic.
|
||||
/// * `config` - the render config for this variant.
|
||||
/// * `explicit` - a template path overriding the lookup.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The rendered document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`render_student`].
|
||||
pub fn render_cohort(
|
||||
layout: &Layout,
|
||||
meta: &Meta,
|
||||
diagnostic: &CohortDiagnostic,
|
||||
config: &RenderConfig,
|
||||
explicit: Option<&std::path::Path>,
|
||||
) -> Result<Document> {
|
||||
render(
|
||||
layout,
|
||||
Variant::CohortReport,
|
||||
&meta.assessment_id,
|
||||
meta,
|
||||
cohort_value(diagnostic, config),
|
||||
config,
|
||||
explicit,
|
||||
)
|
||||
}
|
||||
|
||||
/// The shared rendering path.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render(
|
||||
layout: &Layout,
|
||||
variant: Variant,
|
||||
assessment_id: &str,
|
||||
meta: &Meta,
|
||||
data: Value,
|
||||
config: &RenderConfig,
|
||||
explicit: Option<&std::path::Path>,
|
||||
) -> Result<Document> {
|
||||
let template = template::load(layout, variant, Some(assessment_id), explicit)?;
|
||||
|
||||
let mut bodies = Vec::new();
|
||||
if template.wants(Slot::Meta) {
|
||||
bodies.push((
|
||||
Slot::Meta,
|
||||
binding(&config.meta_binding, &meta.value(config)),
|
||||
));
|
||||
}
|
||||
if template.wants(Slot::Data) {
|
||||
bodies.push((Slot::Data, binding(&config.data_binding, &data)));
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if template.is_inert() {
|
||||
warnings.push(format!(
|
||||
"the template {} declares no coursebank markers, so the report is empty; add `// \
|
||||
coursebank:data` where the body belongs",
|
||||
template.origin
|
||||
));
|
||||
} else if !template.wants(Slot::Data) {
|
||||
warnings.push(format!(
|
||||
"the template {} declares no `data` slot, so it received the metadata but not the \
|
||||
report itself",
|
||||
template.origin
|
||||
));
|
||||
}
|
||||
if template.wants(Slot::Questions) {
|
||||
return Err(Error::Invalid(vec![format!(
|
||||
"the template {} declares a `questions` slot, but a diagnostic report carries no \
|
||||
questions to fill it with. Remove the marker: a report that reproduces the exam \
|
||||
cannot be returned before a makeup is given",
|
||||
template.origin
|
||||
)]));
|
||||
}
|
||||
|
||||
Ok(Document {
|
||||
variant,
|
||||
origin: template.origin.clone(),
|
||||
slots: template.slots(),
|
||||
text: template.render(&bodies),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// The file stem a student's report is written under.
|
||||
///
|
||||
/// Uses the student key rather than the name: a key is unique, filesystem-safe,
|
||||
/// and already a pseudonym when the store is pseudonymized.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `assessment_id` - the assessment id.
|
||||
/// * `student_key` - the student key.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The stem, with no extension.
|
||||
pub fn student_stem(assessment_id: &str, student_key: &str) -> String {
|
||||
let safe: String = student_key
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{assessment_id}-{safe}")
|
||||
}
|
||||
|
||||
/// Summarizes what a set of rendered reports covered, for the command line.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `written` - how many files were written.
|
||||
/// * `students` - how many students they cover.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A sentence.
|
||||
pub fn summary(written: usize, students: usize) -> String {
|
||||
format!(
|
||||
"{written} file(s) for {students} student(s); compile them with `typst compile` or the \
|
||||
loop in the guide"
|
||||
)
|
||||
}
|
||||
|
||||
/// Tallies what a class diagnostic would tell you to do next.
|
||||
///
|
||||
/// Kept here rather than in the template so that the command line and the PDF
|
||||
/// agree about what counts as a finding.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `diagnostic` - the class diagnostic.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Short lines, most important first.
|
||||
pub fn headline(diagnostic: &CohortDiagnostic) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
out.push(format!(
|
||||
"{} student(s), mean {:.0}% (SD {:.1}), median {:.0}%",
|
||||
diagnostic.n_students,
|
||||
diagnostic.distribution.mean,
|
||||
diagnostic.distribution.sd,
|
||||
diagnostic.distribution.median
|
||||
));
|
||||
if !diagnostic.gaps.is_empty() {
|
||||
out.push(format!(
|
||||
"{} objective(s) the class did not meet; worst is {} at {:.0}%",
|
||||
diagnostic.gaps.len(),
|
||||
diagnostic.gaps[0].id,
|
||||
diagnostic.gaps[0].rate * 100.0
|
||||
));
|
||||
}
|
||||
if !diagnostic.revise.is_empty() {
|
||||
let numbers: Vec<String> = diagnostic
|
||||
.revise
|
||||
.iter()
|
||||
.take(6)
|
||||
.map(|q| format!("q{}", q.number))
|
||||
.collect();
|
||||
out.push(format!(
|
||||
"{} question(s) to look at before reuse: {}",
|
||||
diagnostic.revise.len(),
|
||||
numbers.join(", ")
|
||||
));
|
||||
}
|
||||
if diagnostic.forms.len() > 1 {
|
||||
let spread = diagnostic
|
||||
.forms
|
||||
.iter()
|
||||
.map(|f| f.mean)
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
- diagnostic
|
||||
.forms
|
||||
.iter()
|
||||
.map(|f| f.mean)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
out.push(format!(
|
||||
"{} forms, {:.0} points apart at the mean",
|
||||
diagnostic.forms.len(),
|
||||
spread
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::diagnostic::{Distribution, ReliabilityRow};
|
||||
|
||||
fn empty_cohort() -> CohortDiagnostic {
|
||||
CohortDiagnostic {
|
||||
n_students: 24,
|
||||
n_items: 36,
|
||||
distribution: Distribution {
|
||||
mean: 72.5,
|
||||
median: 74.0,
|
||||
sd: 12.0,
|
||||
min: 41.0,
|
||||
max: 97.0,
|
||||
bins: vec![Bin {
|
||||
low: 70,
|
||||
high: 80,
|
||||
count: 9,
|
||||
}],
|
||||
},
|
||||
reliability: ReliabilityRow {
|
||||
alpha: Some(0.71),
|
||||
sem: Some(2.1),
|
||||
mean_p: 0.72,
|
||||
mean_point_biserial: Some(0.24),
|
||||
interpretation: "acceptable for a classroom exam".into(),
|
||||
},
|
||||
levels: Vec::new(),
|
||||
objectives: Vec::new(),
|
||||
gaps: Vec::new(),
|
||||
questions: Vec::new(),
|
||||
revise: Vec::new(),
|
||||
forms: Vec::new(),
|
||||
blueprint: Vec::new(),
|
||||
patterns: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_headline_leads_with_the_distribution() {
|
||||
let lines = headline(&empty_cohort());
|
||||
assert!(lines[0].contains("24 student(s)"), "{lines:?}");
|
||||
assert!(
|
||||
lines[0].contains("73%") || lines[0].contains("72%"),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_student_stem_is_filesystem_safe() {
|
||||
assert_eq!(student_stem("e1", "s-9f8e7d"), "e1-s-9f8e7d");
|
||||
assert_eq!(student_stem("e1", "ada@x.edu"), "e1-ada-x-edu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cohort_value_carries_no_question_text() {
|
||||
let config = RenderConfig::for_variant(Variant::CohortReport);
|
||||
let text = cohort_value(&empty_cohort(), &config).to_typst(0);
|
||||
assert!(text.contains("reliability"));
|
||||
assert!(!text.contains("stem"));
|
||||
}
|
||||
}
|
||||
@@ -1123,7 +1123,7 @@ fn rewrite_latex_math(source: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn markup_value(source: &str, content: bool) -> Value {
|
||||
pub(crate) fn markup_value(source: &str, content: bool) -> Value {
|
||||
let source = rewrite_latex_math(source);
|
||||
if content {
|
||||
Value::content(source)
|
||||
|
||||
@@ -76,6 +76,12 @@ const EMBEDDED_KEY: &str = include_str!("templates/key.typ");
|
||||
/// The bundled answer sheet template.
|
||||
const EMBEDDED_ANSWER_SHEET: &str = include_str!("templates/answer-sheet.typ");
|
||||
|
||||
/// The bundled student diagnostic template.
|
||||
const EMBEDDED_STUDENT_REPORT: &str = include_str!("templates/student-report.typ");
|
||||
|
||||
/// The bundled class diagnostic template.
|
||||
const EMBEDDED_COHORT_REPORT: &str = include_str!("templates/cohort-report.typ");
|
||||
|
||||
/// An injection point a template can declare.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Slot {
|
||||
@@ -174,6 +180,8 @@ pub fn embedded(variant: Variant) -> &'static str {
|
||||
Variant::Exam => EMBEDDED_EXAM,
|
||||
Variant::Key => EMBEDDED_KEY,
|
||||
Variant::AnswerSheet => EMBEDDED_ANSWER_SHEET,
|
||||
Variant::StudentReport => EMBEDDED_STUDENT_REPORT,
|
||||
Variant::CohortReport => EMBEDDED_COHORT_REPORT,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
// coursebank — class diagnostic template
|
||||
//
|
||||
// This file is a template, not generated output. `coursebank report cohort`
|
||||
// replaces only the marked regions below.
|
||||
//
|
||||
// coursebank template dump --variant cohort-report
|
||||
// typst watch templates/cohort-report.typ
|
||||
//
|
||||
// Markers:
|
||||
//
|
||||
// // coursebank:begin meta course, assessment, class size, policy
|
||||
// // coursebank:end meta
|
||||
// // coursebank:begin data the class diagnostic
|
||||
// // coursebank:end data
|
||||
//
|
||||
// This document is for you, not for the class. It carries item statistics, the
|
||||
// revision queue, and the per-option breakdown — everything the student report
|
||||
// withholds except the question text itself, which is in the bank where it
|
||||
// belongs. Do not hand it out: an option table tells a reader which letter was
|
||||
// keyed.
|
||||
|
||||
#import "@preview/mitex:0.2.7": mi
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// coursebank:begin meta
|
||||
#let cb-meta = (
|
||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026f"),
|
||||
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
|
||||
generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"),
|
||||
policy: (mastery-threshold: 0.75, min-items-for-mastery: 2),
|
||||
class-size: 24,
|
||||
extra: (:),
|
||||
)
|
||||
// coursebank:end meta
|
||||
|
||||
// coursebank:begin data
|
||||
#let cb-data = (
|
||||
students: 24,
|
||||
items: 36,
|
||||
distribution: (
|
||||
mean: 71.2, median: 73.0, sd: 11.4, min: 44.0, max: 94.0,
|
||||
bins: (
|
||||
(low: 40, high: 50, count: 1),
|
||||
(low: 50, high: 60, count: 3),
|
||||
(low: 60, high: 70, count: 6),
|
||||
(low: 70, high: 80, count: 9),
|
||||
(low: 80, high: 90, count: 4),
|
||||
(low: 90, high: 100, count: 1),
|
||||
),
|
||||
),
|
||||
reliability: (
|
||||
alpha: 0.71, sem: 2.1, mean-p: 0.72, mean-point-biserial: 0.24,
|
||||
interpretation: "Reliability is 0.71, acceptable for a classroom exam.",
|
||||
),
|
||||
levels: (
|
||||
(level: 1, name: "Remember", items: 6, rate: 0.91),
|
||||
(level: 3, name: "Apply", items: 9, rate: 0.64),
|
||||
),
|
||||
objectives: (
|
||||
(id: "lo-sample-gap", text: [A sample objective the class struggled with.],
|
||||
items: 3, rate: 0.41, meeting: 4, developing: 7, not-yet: 13, thin: 0,
|
||||
below-threshold: true),
|
||||
),
|
||||
gaps: (
|
||||
(id: "lo-sample-gap", text: [A sample objective the class struggled with.],
|
||||
items: 3, rate: 0.41, meeting: 4, developing: 7, not-yet: 13, thin: 0,
|
||||
below-threshold: true),
|
||||
),
|
||||
questions: (
|
||||
(number: 1, item: "bank::q-sample-001", level: 1, objectives: ("lo-sample-gap",),
|
||||
p: 0.42, point-biserial: 0.05, discrimination: 0.10, blank-rate: 0.0,
|
||||
key: ("B",),
|
||||
options: (
|
||||
(letter: "A", count: 9, rate: 0.375, is-key: false, point-biserial: 0.11, nonfunctioning: false),
|
||||
(letter: "B", count: 10, rate: 0.417, is-key: true, point-biserial: 0.05, nonfunctioning: false),
|
||||
(letter: "C", count: 5, rate: 0.208, is-key: false, point-biserial: -0.2, nonfunctioning: false),
|
||||
(letter: "D", count: 0, rate: 0.0, is-key: false, nonfunctioning: true),
|
||||
),
|
||||
flags: ("ambiguous",),
|
||||
notes: ([Distractor A drew as many strong students as the key.],),
|
||||
by-form: (A: 0.55, B: 0.30)),
|
||||
),
|
||||
revise: (),
|
||||
forms: (
|
||||
(id: "A", students: 12, mean: 73.5, sd: 10.2),
|
||||
(id: "B", students: 12, mean: 68.9, sd: 12.1),
|
||||
),
|
||||
blueprint: (),
|
||||
patterns: (),
|
||||
warnings: (),
|
||||
)
|
||||
// coursebank:end data
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Settings
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let extra = cb-meta.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: "9.5pt"))
|
||||
#let paper = extra.at("paper", default: "us-letter")
|
||||
#let show-options = extra.at("option-tables", default: true)
|
||||
|
||||
#let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75)
|
||||
|
||||
#let ok-color = rgb("#2a9d8f")
|
||||
#let mid-color = rgb("#D19F1F")
|
||||
#let bad-color = rgb("#E24E29")
|
||||
|
||||
#set page(
|
||||
paper: paper,
|
||||
margin: (x: 1.7cm, y: 2.0cm),
|
||||
header: text(size: 0.8em, fill: luma(110))[
|
||||
#cb-meta.course.code · #cb-meta.assessment.title · class diagnostic
|
||||
#h(1fr)
|
||||
instructor copy
|
||||
],
|
||||
footer: context text(size: 0.8em, fill: luma(110))[
|
||||
#h(1fr)
|
||||
Page #counter(page).display("1 of 1", both: true)
|
||||
],
|
||||
)
|
||||
#set text(font: body-font, size: body-size, lang: "en")
|
||||
#set par(justify: false, leading: 0.6em)
|
||||
#show heading.where(level: 1): it => block(above: 1.4em, below: 0.7em)[
|
||||
#text(size: 1.15em, weight: "bold", fill: accent)[#it.body]
|
||||
#v(-0.45em)
|
||||
#line(length: 100%, stroke: 0.6pt + accent.lighten(55%))
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
|
||||
#let pct(rate) = str(calc.round(rate * 100)) + "%"
|
||||
#let num(value, digits: 2) = str(calc.round(value, digits: digits))
|
||||
#let signed(value) = (if value >= 0 { "+" } else { "" }) + num(value)
|
||||
|
||||
#let rate-color(rate) = {
|
||||
if rate >= threshold { ok-color }
|
||||
else if rate >= threshold * 0.6 { mid-color }
|
||||
else { bad-color }
|
||||
}
|
||||
|
||||
#let badge(label, color) = box(
|
||||
fill: color.lighten(82%),
|
||||
radius: 3pt,
|
||||
inset: (x: 4pt, y: 2pt),
|
||||
)[#text(size: 0.7em, weight: "bold", fill: color.darken(12%))[#label]]
|
||||
|
||||
#let bar(rate, color: accent, width: 2.4cm) = {
|
||||
let r = calc.max(0.0, calc.min(1.0, rate))
|
||||
box(baseline: 0.15em)[
|
||||
#stack(
|
||||
dir: ltr,
|
||||
box(width: width * r, height: 0.58em, fill: color, radius: (left: 2pt)),
|
||||
box(width: width * (1.0 - r), height: 0.58em, fill: luma(232), radius: (right: 2pt)),
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
#let stat-card(label, value, note: none) = block(
|
||||
width: 100%,
|
||||
fill: luma(247),
|
||||
radius: 4pt,
|
||||
inset: (x: 9pt, y: 8pt),
|
||||
)[
|
||||
#text(size: 0.74em, fill: luma(95))[#upper(label)]
|
||||
#v(0.12em)
|
||||
#text(size: 1.35em, weight: "bold", fill: accent)[#value]
|
||||
#if note != none [
|
||||
#v(0.08em)
|
||||
#text(size: 0.76em, fill: luma(95))[#note]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Heading and headline numbers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#block(below: 1em)[
|
||||
#text(size: 1.5em, weight: "bold")[#cb-meta.assessment.title — class diagnostic]
|
||||
#linebreak()
|
||||
#text(size: 0.95em, fill: luma(110))[
|
||||
#cb-meta.course.code · #cb-meta.course.term
|
||||
#{
|
||||
let date = cb-meta.assessment.at("date", default: none)
|
||||
if date != none [ · administered #date ]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
#let dist = cb-data.distribution
|
||||
#let rel = cb-data.reliability
|
||||
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr, 1fr),
|
||||
gutter: 9pt,
|
||||
stat-card("students", str(cb-data.students), note: str(cb-data.items) + " scored items"),
|
||||
stat-card("mean", str(calc.round(dist.mean)) + "%", note: "median " + str(calc.round(dist.median)) + "%"),
|
||||
stat-card("spread", "SD " + num(dist.sd, digits: 1), note: str(calc.round(dist.min)) + "–" + str(calc.round(dist.max)) + "%"),
|
||||
stat-card(
|
||||
"reliability",
|
||||
{
|
||||
let alpha = rel.at("alpha", default: none)
|
||||
if alpha != none { num(alpha) } else { "n/a" }
|
||||
},
|
||||
note: {
|
||||
let sem = rel.at("sem", default: none)
|
||||
if sem != none { "SEM " + num(sem, digits: 1) + " items" } else { "KR-20" }
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
#v(0.6em)
|
||||
#text(size: 0.88em, fill: luma(90))[#rel.interpretation]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Distribution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let bins = dist.at("bins", default: ())
|
||||
#let peak = if bins.len() > 0 { calc.max(..bins.map(b => b.count)) } else { 0 }
|
||||
|
||||
#if peak > 0 [
|
||||
= Score distribution
|
||||
|
||||
#let height = 3.2cm
|
||||
#align(center)[
|
||||
#grid(
|
||||
columns: (1fr,) * bins.len(),
|
||||
gutter: 5pt,
|
||||
..bins.map(b => {
|
||||
let share = if peak > 0 { b.count / peak } else { 0.0 }
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: 3pt,
|
||||
align(center + bottom)[
|
||||
#box(height: height)[
|
||||
#align(bottom)[
|
||||
#box(
|
||||
width: 100%,
|
||||
height: height * share,
|
||||
fill: accent.lighten(if b.low >= 70 { 55% } else { 25% }),
|
||||
radius: (top: 2pt),
|
||||
)
|
||||
]
|
||||
]
|
||||
],
|
||||
align(center)[#text(size: 0.72em, weight: "bold")[#b.count]],
|
||||
align(center)[#text(size: 0.68em, fill: luma(110))[#b.low]],
|
||||
)
|
||||
}),
|
||||
)
|
||||
]
|
||||
#align(center)[#text(size: 0.72em, fill: luma(120))[percent scored, in ten-point bins]]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Forms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let forms = cb-data.at("forms", default: ())
|
||||
|
||||
#if forms.len() > 1 [
|
||||
= Forms
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Forms differ only in order, so their means should differ only by who sat
|
||||
them. A persistent gap points at an item whose permutation made it easier or
|
||||
harder, and the per-question columns further down are where to look.
|
||||
]
|
||||
|
||||
#v(0.4em)
|
||||
|
||||
#table(
|
||||
columns: (auto, auto, auto, auto),
|
||||
stroke: none,
|
||||
align: (left, right, right, right),
|
||||
inset: (x: 7pt, y: 4pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[FORM],
|
||||
text(size: 0.78em, fill: luma(95))[STUDENTS],
|
||||
text(size: 0.78em, fill: luma(95))[MEAN],
|
||||
text(size: 0.78em, fill: luma(95))[SD],
|
||||
),
|
||||
..forms.map(form => (
|
||||
[*#form.id*],
|
||||
[#form.students],
|
||||
[#num(form.mean, digits: 1)%],
|
||||
[#num(form.sd, digits: 1)],
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Levels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if cb-data.levels.len() > 0 [
|
||||
= By level
|
||||
|
||||
#table(
|
||||
columns: (auto, auto, auto, 1fr),
|
||||
stroke: none,
|
||||
align: (left + horizon, right + horizon, right + horizon, left + horizon),
|
||||
inset: (x: 7pt, y: 4pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[LEVEL],
|
||||
text(size: 0.78em, fill: luma(95))[ITEMS],
|
||||
text(size: 0.78em, fill: luma(95))[CLASS],
|
||||
text(size: 0.78em, fill: luma(95))[],
|
||||
),
|
||||
..cb-data.levels.map(level => (
|
||||
[#level.level · *#level.name*],
|
||||
[#level.items],
|
||||
[#pct(level.rate)],
|
||||
bar(level.rate, color: rate-color(level.rate), width: 5cm),
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Objectives
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let objectives = cb-data.at("objectives", default: ())
|
||||
|
||||
#if objectives.len() > 0 [
|
||||
= By objective, worst first
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
The counts split the class into how many are meeting, developing, and not yet
|
||||
on each objective. An objective where the class divides evenly is a different
|
||||
teaching problem from one where nearly everyone is short.
|
||||
]
|
||||
|
||||
#v(0.4em)
|
||||
|
||||
#table(
|
||||
columns: (1fr, auto, auto, auto, auto, auto, auto),
|
||||
stroke: none,
|
||||
align: (left + horizon, right + horizon, right + horizon, right + horizon, right + horizon, right + horizon, left + horizon),
|
||||
inset: (x: 6pt, y: 4pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[OBJECTIVE],
|
||||
text(size: 0.78em, fill: luma(95))[Q],
|
||||
text(size: 0.78em, fill: luma(95))[CLASS],
|
||||
text(size: 0.78em, fill: luma(95))[MET],
|
||||
text(size: 0.78em, fill: luma(95))[DEV],
|
||||
text(size: 0.78em, fill: luma(95))[NOT],
|
||||
text(size: 0.78em, fill: luma(95))[],
|
||||
),
|
||||
..objectives.map(objective => (
|
||||
{
|
||||
markup(objective.text)
|
||||
if objective.at("below-threshold", default: false) {
|
||||
[ #badge("below " + pct(threshold), bad-color)]
|
||||
}
|
||||
},
|
||||
[#objective.items],
|
||||
[#pct(objective.rate)],
|
||||
[#objective.meeting],
|
||||
[#objective.developing],
|
||||
[#objective.at("not-yet", default: 0)],
|
||||
bar(objective.rate, color: rate-color(objective.rate), width: 2.2cm),
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Items
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let questions = cb-data.at("questions", default: ())
|
||||
|
||||
#if questions.len() > 0 [
|
||||
#pagebreak(weak: true)
|
||||
= Item analysis
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
#emph[p] is the share answering correctly; #emph[r] is the corrected
|
||||
item-total correlation, which should be positive and is the single most
|
||||
useful column; #emph[D] is the upper group minus the lower group. Option
|
||||
letters are the bank's, not any one form's.
|
||||
]
|
||||
|
||||
#v(0.4em)
|
||||
|
||||
#table(
|
||||
columns: (auto, auto, auto, auto, auto, auto, 1fr),
|
||||
stroke: none,
|
||||
align: (right + horizon, right + horizon, right + horizon, right + horizon, right + horizon, right + horizon, left + horizon),
|
||||
inset: (x: 5pt, y: 3.5pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[Q],
|
||||
text(size: 0.78em, fill: luma(95))[LVL],
|
||||
text(size: 0.78em, fill: luma(95))[KEY],
|
||||
text(size: 0.78em, fill: luma(95))[p],
|
||||
text(size: 0.78em, fill: luma(95))[r],
|
||||
text(size: 0.78em, fill: luma(95))[D],
|
||||
text(size: 0.78em, fill: luma(95))[FLAGS],
|
||||
),
|
||||
..questions.map(q => (
|
||||
[*#q.number*],
|
||||
{
|
||||
let level = q.at("level", default: none)
|
||||
if level != none [#level] else []
|
||||
},
|
||||
[#q.at("key", default: ()).join("")],
|
||||
text(fill: rate-color(q.p))[#num(q.p)],
|
||||
{
|
||||
let r = q.at("point-biserial", default: none)
|
||||
if r == none { text(fill: luma(140))[n/a] }
|
||||
else if r < 0.0 { text(fill: bad-color, weight: "bold")[#signed(r)] }
|
||||
else if r < 0.15 { text(fill: mid-color)[#signed(r)] }
|
||||
else { [#signed(r)] }
|
||||
},
|
||||
{
|
||||
let d = q.at("discrimination", default: none)
|
||||
if d != none [#signed(d)] else [—]
|
||||
},
|
||||
{
|
||||
let flags = q.at("flags", default: ())
|
||||
let forms-gap = {
|
||||
let by-form = q.at("by-form", default: (:))
|
||||
let values = by-form.values()
|
||||
if values.len() > 1 and calc.max(..values) - calc.min(..values) >= 0.25 {
|
||||
(badge("form gap " + pct(calc.max(..values) - calc.min(..values)), mid-color),)
|
||||
} else { () }
|
||||
}
|
||||
stack(
|
||||
dir: ltr,
|
||||
spacing: 3pt,
|
||||
..flags.map(f => badge(f, bad-color)),
|
||||
..forms-gap,
|
||||
)
|
||||
},
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The revise queue
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let revise = cb-data.at("revise", default: ())
|
||||
|
||||
#if revise.len() > 0 [
|
||||
= Before you use these again
|
||||
|
||||
#for q in revise [
|
||||
#block(breakable: false, above: 0.9em, width: 100%)[
|
||||
#text(weight: "bold", fill: accent)[Question #q.number]
|
||||
#{
|
||||
let item = q.at("item", default: none)
|
||||
if item != none [ #text(size: 0.8em, fill: luma(120))[#item]]
|
||||
}
|
||||
#h(0.5em)
|
||||
#stack(dir: ltr, spacing: 3pt, ..q.at("flags", default: ()).map(f => badge(f, bad-color)))
|
||||
|
||||
#v(0.25em)
|
||||
#for note in q.at("notes", default: ()) [
|
||||
#text(size: 0.9em)[— #markup(note)]
|
||||
#linebreak()
|
||||
]
|
||||
|
||||
#if show-options and q.at("options", default: ()).len() > 0 [
|
||||
#v(0.3em)
|
||||
#table(
|
||||
columns: (auto, auto, auto, auto, 1fr),
|
||||
stroke: none,
|
||||
align: (center + horizon, right + horizon, right + horizon, left + horizon, left + horizon),
|
||||
inset: (x: 5pt, y: 3pt),
|
||||
table.header(
|
||||
text(size: 0.74em, fill: luma(95))[OPT],
|
||||
text(size: 0.74em, fill: luma(95))[n],
|
||||
text(size: 0.74em, fill: luma(95))[SHARE],
|
||||
text(size: 0.74em, fill: luma(95))[],
|
||||
text(size: 0.74em, fill: luma(95))[r],
|
||||
),
|
||||
..q.options.map(option => (
|
||||
{
|
||||
if option.at("is-key", default: false) {
|
||||
text(weight: "bold", fill: ok-color)[#option.letter]
|
||||
} else { [#option.letter] }
|
||||
},
|
||||
[#option.count],
|
||||
[#pct(option.rate)],
|
||||
bar(
|
||||
option.rate,
|
||||
color: if option.at("is-key", default: false) { ok-color } else { luma(160) },
|
||||
width: 2.6cm,
|
||||
),
|
||||
{
|
||||
let r = option.at("point-biserial", default: none)
|
||||
if r == none { text(fill: luma(150))[—] }
|
||||
else if not option.at("is-key", default: false) and r > 0.0 {
|
||||
text(fill: mid-color)[#signed(r)]
|
||||
} else { text(size: 0.95em)[#signed(r)] }
|
||||
},
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Blueprint and cautions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let blueprint = cb-data.at("blueprint", default: ())
|
||||
#let warnings = cb-data.at("warnings", default: ())
|
||||
#let patterns = cb-data.at("patterns", default: ())
|
||||
|
||||
#if patterns.len() > 0 [
|
||||
= Response profiles
|
||||
|
||||
#for pattern in patterns [
|
||||
- *#pattern.label* — #pattern.students student(s)
|
||||
]
|
||||
]
|
||||
|
||||
#if blueprint.len() > 0 [
|
||||
= Where the form differs from its blueprint
|
||||
|
||||
#for line in blueprint [
|
||||
- #line
|
||||
]
|
||||
]
|
||||
|
||||
#if warnings.len() > 0 [
|
||||
= Cautions
|
||||
|
||||
#for line in warnings [
|
||||
- #text(size: 0.9em)[#line]
|
||||
]
|
||||
]
|
||||
|
||||
#v(1.2em)
|
||||
#line(length: 100%, stroke: 0.5pt + luma(210))
|
||||
#v(0.4em)
|
||||
#text(size: 0.76em, fill: luma(120))[
|
||||
Generated #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version.
|
||||
With #cb-data.students students, an item statistic has a standard error of
|
||||
roughly 0.2; treat single-item results as provisional and the pattern across
|
||||
items as real. Instructor copy — the option tables identify the key.
|
||||
]
|
||||
@@ -0,0 +1,552 @@
|
||||
// coursebank — individual diagnostic template
|
||||
//
|
||||
// This file is a template, not generated output. `coursebank report students`
|
||||
// replaces only the marked regions below and leaves every other line exactly as
|
||||
// you wrote it, so this is where layout decisions belong.
|
||||
//
|
||||
// coursebank template dump --variant student-report
|
||||
// typst watch templates/student-report.typ
|
||||
//
|
||||
// Two markers are in play:
|
||||
//
|
||||
// // coursebank:begin meta course, assessment, class size, policy
|
||||
// // coursebank:end meta
|
||||
// // coursebank:begin data one student's diagnostic
|
||||
// // coursebank:end data
|
||||
//
|
||||
// Both regions ship with sample values, so `typst watch` works before any report
|
||||
// has been generated.
|
||||
//
|
||||
// What is not in the payload: the questions. There is no stem field and no option
|
||||
// text field, on any question, in any configuration. A student report is handed
|
||||
// back before the makeup exam is given, and a report that reproduces the paper
|
||||
// cannot be. If you find yourself wanting to print the question, print its number
|
||||
// and let the student read it off their own copy.
|
||||
|
||||
#import "@preview/mitex:0.2.7": mi
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// coursebank:begin meta
|
||||
#let cb-meta = (
|
||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026f"),
|
||||
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
|
||||
generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"),
|
||||
policy: (mastery-threshold: 0.75, min-items-for-mastery: 2),
|
||||
class-size: 24,
|
||||
extra: (:),
|
||||
)
|
||||
// coursebank:end meta
|
||||
|
||||
// coursebank:begin data
|
||||
#let cb-data = (
|
||||
student-key: "s-000000000000",
|
||||
name: "Sample Student",
|
||||
form: "A",
|
||||
score: (points: 27.0, possible: 36.0, percent: 75.0, bonus: 1.0, correct: 27, items: 36),
|
||||
standing: (class-mean: 71.2, class-sd: 11.4, band: "upper half"),
|
||||
levels: (
|
||||
(level: 1, name: "Remember", blurb: "recalling terms, facts, and definitions",
|
||||
items: 6, rate: 1.0, class-rate: 0.91, comparison: "above the class"),
|
||||
(level: 3, name: "Apply", blurb: "using a procedure in a new situation",
|
||||
items: 9, rate: 0.56, class-rate: 0.64, comparison: "below the class"),
|
||||
),
|
||||
objectives: (
|
||||
(id: "lo-sample-met", text: [A sample objective this student met.], items: 3,
|
||||
credit: 3.0, rate: 1.0, lower: 0.44, upper: 1.0, class-rate: 0.81,
|
||||
status: "meeting", symbol: "✓", confident: false, thin-evidence: false, levels: (1, 2)),
|
||||
(id: "lo-sample-gap", text: [A sample objective to work on.], items: 3,
|
||||
credit: 1.0, rate: 0.33, lower: 0.06, upper: 0.79, class-rate: 0.58,
|
||||
status: "not yet", symbol: "✗", confident: false, thin-evidence: false, levels: (3,)),
|
||||
),
|
||||
strengths: ((id: "lo-sample-met", text: [A sample objective this student met.], rate: 1.0, items: 3),),
|
||||
focus: ((id: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, items: 3),),
|
||||
questions: (
|
||||
(number: 1, level: 1, objectives: ("lo-sample-met",), correct: true, credit: 1.0,
|
||||
bonus: false, blank: false, class-rate: 0.91, taught-in: ()),
|
||||
(number: 2, level: 3, objectives: ("lo-sample-gap",), correct: false, credit: 0.0,
|
||||
bonus: false, blank: false, class-rate: 0.58,
|
||||
feedback: [This is the note written for the option that was chosen.],
|
||||
taught-in: ("Enthalpy (L1.1), slides 12, 13",)),
|
||||
),
|
||||
study: (
|
||||
(objective: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33,
|
||||
readings: (
|
||||
(citation: "KKW §6.2", lecture: "L1.1", lecture-title: "Enthalpy",
|
||||
focus: [What to take from this section.], supplemental: false),
|
||||
)),
|
||||
),
|
||||
)
|
||||
// coursebank:end data
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Settings
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let extra = cb-meta.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: "10pt"))
|
||||
#let paper = extra.at("paper", default: "us-letter")
|
||||
|
||||
// Turn sections off from templates/typst.yaml rather than by deleting code, so
|
||||
// a course that does not want the question map keeps the rest of this file.
|
||||
#let show-question-map = extra.at("question-map", default: true)
|
||||
#let show-feedback = extra.at("feedback", default: true)
|
||||
#let show-study = extra.at("study-plan", default: true)
|
||||
#let show-comparison = extra.at("comparison", default: true)
|
||||
|
||||
#let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75)
|
||||
|
||||
#let ok-color = rgb("#2a9d8f")
|
||||
#let mid-color = rgb("#D19F1F")
|
||||
#let bad-color = rgb("#E24E29")
|
||||
#let thin-color = luma(150)
|
||||
|
||||
#set page(
|
||||
paper: paper,
|
||||
margin: (x: 1.9cm, y: 2.1cm),
|
||||
header: text(size: 0.8em, fill: luma(110))[
|
||||
#cb-meta.course.code · #cb-meta.assessment.title · individual diagnostic
|
||||
],
|
||||
footer: context text(size: 0.8em, fill: luma(110))[
|
||||
#h(1fr)
|
||||
Page #counter(page).display("1 of 1", both: true)
|
||||
],
|
||||
)
|
||||
#set text(font: body-font, size: body-size, lang: "en")
|
||||
#set par(justify: false, leading: 0.62em)
|
||||
#show heading.where(level: 1): it => block(above: 1.4em, below: 0.7em)[
|
||||
#text(size: 1.15em, weight: "bold", fill: accent)[#it.body]
|
||||
#v(-0.45em)
|
||||
#line(length: 100%, stroke: 0.6pt + accent.lighten(55%))
|
||||
]
|
||||
#show heading.where(level: 2): it => block(above: 1em, below: 0.45em)[
|
||||
#text(size: 1.02em, weight: "bold")[#it.body]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
|
||||
|
||||
// A rate is a fraction in 0..1; a percent is already out of 100. Keeping the two
|
||||
// straight is the only arithmetic this template does.
|
||||
#let pct(rate) = str(calc.round(rate * 100)) + "%"
|
||||
#let pct1(value) = str(calc.round(value, digits: 0)) + "%"
|
||||
|
||||
#let status-color(status) = {
|
||||
if status == "meeting" { ok-color }
|
||||
else if status == "developing" { mid-color }
|
||||
else if status == "not yet" { bad-color }
|
||||
else { thin-color }
|
||||
}
|
||||
|
||||
#let rate-color(rate) = {
|
||||
if rate >= threshold { ok-color }
|
||||
else if rate >= threshold * 0.6 { mid-color }
|
||||
else { bad-color }
|
||||
}
|
||||
|
||||
#let badge(label, color) = box(
|
||||
fill: color.lighten(82%),
|
||||
radius: 3pt,
|
||||
inset: (x: 5pt, y: 2.5pt),
|
||||
)[#text(size: 0.72em, weight: "bold", fill: color.darken(12%))[#label]]
|
||||
|
||||
// Two boxes side by side rather than an overlay: no `place`, no coordinate
|
||||
// arithmetic, and it degrades to something sensible at any width.
|
||||
#let bar(rate, color: accent, width: 3.6cm) = {
|
||||
let r = calc.max(0.0, calc.min(1.0, rate))
|
||||
box(baseline: 0.15em)[
|
||||
#stack(
|
||||
dir: ltr,
|
||||
box(width: width * r, height: 0.62em, fill: color, radius: (left: 2pt)),
|
||||
box(width: width * (1.0 - r), height: 0.62em, fill: luma(232), radius: (right: 2pt)),
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
#let stat-card(label, value, note: none) = block(
|
||||
width: 100%,
|
||||
fill: luma(247),
|
||||
radius: 4pt,
|
||||
inset: (x: 10pt, y: 9pt),
|
||||
)[
|
||||
#text(size: 0.75em, fill: luma(95))[#upper(label)]
|
||||
#v(0.15em)
|
||||
#text(size: 1.45em, weight: "bold", fill: accent)[#value]
|
||||
#if note != none [
|
||||
#v(0.1em)
|
||||
#text(size: 0.78em, fill: luma(95))[#note]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Heading
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#block(below: 0.6em)[
|
||||
#text(size: 1.5em, weight: "bold")[#cb-meta.assessment.title]
|
||||
#h(0.6em)
|
||||
#text(size: 1em, fill: luma(110))[
|
||||
#cb-meta.course.code — #cb-meta.course.title
|
||||
]
|
||||
]
|
||||
|
||||
#block(below: 1.2em)[
|
||||
#text(size: 0.95em)[
|
||||
*#cb-data.at("name", default: cb-data.student-key)*
|
||||
#{
|
||||
let sid = cb-data.at("sid", default: none)
|
||||
if sid != none [ · #sid ]
|
||||
}
|
||||
#{
|
||||
let form = cb-data.at("form", default: none)
|
||||
if form != none [ · Form #form ]
|
||||
}
|
||||
#{
|
||||
let date = cb-meta.assessment.at("date", default: none)
|
||||
if date != none [ · #date ]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Score
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let score = cb-data.score
|
||||
#let standing = cb-data.at("standing", default: none)
|
||||
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr),
|
||||
gutter: 10pt,
|
||||
stat-card(
|
||||
"score",
|
||||
pct1(score.percent),
|
||||
note: str(score.points) + " of " + str(score.possible) + " points"
|
||||
+ (if score.at("bonus", default: 0.0) > 0.0 { " (+" + str(score.bonus) + " bonus)" } else { "" }),
|
||||
),
|
||||
stat-card(
|
||||
"questions",
|
||||
str(score.correct) + " / " + str(score.items),
|
||||
note: "answered correctly",
|
||||
),
|
||||
if standing != none and show-comparison {
|
||||
stat-card(
|
||||
"class",
|
||||
pct1(standing.class-mean),
|
||||
note: "class average · you are in the " + standing.band,
|
||||
)
|
||||
} else {
|
||||
stat-card("class size", str(cb-meta.at("class-size", default: 0)), note: "students")
|
||||
},
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Levels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if cb-data.levels.len() > 0 [
|
||||
= How you did, by kind of thinking
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Questions are written at levels. Scoring well on recall and less well on
|
||||
application is a different problem from scoring evenly and low, and it has a
|
||||
different fix.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#table(
|
||||
columns: (auto, 1fr, auto, auto, auto),
|
||||
stroke: none,
|
||||
align: (left + horizon, left + horizon, left + horizon, right + horizon, left + horizon),
|
||||
inset: (x: 5pt, y: 5pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[LEVEL],
|
||||
text(size: 0.78em, fill: luma(95))[WHAT IT ASKS],
|
||||
text(size: 0.78em, fill: luma(95))[YOU],
|
||||
text(size: 0.78em, fill: luma(95))[],
|
||||
text(size: 0.78em, fill: luma(95))[CLASS],
|
||||
),
|
||||
..cb-data.levels.map(level => (
|
||||
[*#level.name* #text(size: 0.8em, fill: luma(120))[(#level.items)]],
|
||||
text(size: 0.85em, fill: luma(80))[#level.blurb],
|
||||
bar(level.rate, color: rate-color(level.rate), width: 2.6cm),
|
||||
[#pct(level.rate)],
|
||||
{
|
||||
let class-rate = level.at("class-rate", default: none)
|
||||
if class-rate != none and show-comparison {
|
||||
text(size: 0.85em, fill: luma(100))[#pct(class-rate)]
|
||||
} else { [] }
|
||||
},
|
||||
)).flatten(),
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Objectives
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#if cb-data.objectives.len() > 0 [
|
||||
= What you have learned, objective by objective
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Each line is one thing the course asked you to be able to do, and how many
|
||||
questions measured it. Two or three questions is thin evidence, so treat a
|
||||
single line as a hint rather than a verdict; the pattern across lines is what
|
||||
to trust.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#table(
|
||||
columns: (auto, 1fr, auto, auto, auto),
|
||||
stroke: none,
|
||||
align: (center + horizon, left + horizon, center + horizon, left + horizon, right + horizon),
|
||||
inset: (x: 5pt, y: 5pt),
|
||||
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
|
||||
table.header(
|
||||
text(size: 0.78em, fill: luma(95))[],
|
||||
text(size: 0.78em, fill: luma(95))[OBJECTIVE],
|
||||
text(size: 0.78em, fill: luma(95))[Q],
|
||||
text(size: 0.78em, fill: luma(95))[YOU],
|
||||
text(size: 0.78em, fill: luma(95))[CLASS],
|
||||
),
|
||||
..cb-data.objectives.map(objective => (
|
||||
text(fill: status-color(objective.status), weight: "bold")[#objective.symbol],
|
||||
{
|
||||
markup(objective.text)
|
||||
if objective.at("thin-evidence", default: false) {
|
||||
[ #badge("too few questions to say", thin-color)]
|
||||
}
|
||||
},
|
||||
text(size: 0.85em, fill: luma(110))[#objective.items],
|
||||
{
|
||||
stack(
|
||||
dir: ltr,
|
||||
spacing: 5pt,
|
||||
bar(objective.rate, color: status-color(objective.status), width: 2.1cm),
|
||||
text(size: 0.9em)[#pct(objective.rate)],
|
||||
)
|
||||
},
|
||||
{
|
||||
let class-rate = objective.at("class-rate", default: none)
|
||||
if class-rate != none and show-comparison {
|
||||
text(size: 0.85em, fill: luma(100))[#pct(class-rate)]
|
||||
} else { [] }
|
||||
},
|
||||
)).flatten(),
|
||||
)
|
||||
|
||||
#v(0.4em)
|
||||
#text(size: 0.78em, fill: luma(110))[
|
||||
#text(fill: ok-color, weight: "bold")[✓] meeting · #text(fill: mid-color, weight: "bold")[~]
|
||||
developing · #text(fill: bad-color, weight: "bold")[✗] not yet ·
|
||||
#text(fill: thin-color, weight: "bold")[?] not enough evidence. The line is
|
||||
drawn at #pct(threshold).
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Strengths and focus
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let strengths = cb-data.at("strengths", default: ())
|
||||
#let focus = cb-data.at("focus", default: ())
|
||||
|
||||
#if strengths.len() > 0 or focus.len() > 0 [
|
||||
= Where to put your time
|
||||
|
||||
#grid(
|
||||
columns: (1fr, 1fr),
|
||||
gutter: 12pt,
|
||||
if focus.len() > 0 {
|
||||
block(
|
||||
width: 100%,
|
||||
fill: bad-color.lighten(93%),
|
||||
radius: 4pt,
|
||||
inset: 10pt,
|
||||
)[
|
||||
#text(weight: "bold", fill: bad-color.darken(15%))[Work on these first]
|
||||
#v(0.35em)
|
||||
#for objective in focus [
|
||||
- #markup(objective.text)
|
||||
#text(size: 0.8em, fill: luma(110))[ (#pct(objective.rate) of #objective.items)]
|
||||
]
|
||||
]
|
||||
} else { [] },
|
||||
if strengths.len() > 0 {
|
||||
block(
|
||||
width: 100%,
|
||||
fill: ok-color.lighten(93%),
|
||||
radius: 4pt,
|
||||
inset: 10pt,
|
||||
)[
|
||||
#text(weight: "bold", fill: ok-color.darken(18%))[Solid, keep it]
|
||||
#v(0.35em)
|
||||
#for objective in strengths [
|
||||
- #markup(objective.text)
|
||||
]
|
||||
]
|
||||
} else { [] },
|
||||
)
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Study plan
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let study = cb-data.at("study", default: ())
|
||||
|
||||
#if show-study and study.len() > 0 [
|
||||
= What to read
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
These are the sections behind the objectives above, taken from the course
|
||||
reading list rather than chosen generically.
|
||||
]
|
||||
|
||||
#v(0.4em)
|
||||
|
||||
#for group in study [
|
||||
#block(breakable: false, above: 0.8em)[
|
||||
== #markup(group.text)
|
||||
|
||||
#for reading in group.readings [
|
||||
#block(inset: (left: 0.8em), above: 0.35em)[
|
||||
#{
|
||||
let url = reading.at("url", default: none)
|
||||
let cite = text(weight: "bold")[#reading.citation]
|
||||
if url != none { link(url)[#cite] } else { cite }
|
||||
}
|
||||
#text(size: 0.85em, fill: luma(110))[
|
||||
— #reading.lecture-title (#reading.lecture)#{
|
||||
if reading.at("supplemental", default: false) { ", supplemental" }
|
||||
}
|
||||
]
|
||||
#{
|
||||
let focus-note = reading.at("focus", default: none)
|
||||
if focus-note != none [
|
||||
\ #text(size: 0.9em)[#markup(focus-note)]
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Question map
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let questions = cb-data.at("questions", default: ())
|
||||
|
||||
#let question-box(q) = {
|
||||
let color = if q.at("blank", default: false) { luma(160) }
|
||||
else if q.at("correct", default: false) == true { ok-color }
|
||||
else if q.at("credit", default: 0.0) > 0.0 { mid-color }
|
||||
else { bad-color }
|
||||
box(
|
||||
width: 100%,
|
||||
fill: color.lighten(85%),
|
||||
stroke: 0.5pt + color.lighten(45%),
|
||||
radius: 3pt,
|
||||
inset: (x: 2pt, y: 4pt),
|
||||
)[
|
||||
#align(center)[
|
||||
#text(size: 0.85em, weight: "bold", fill: color.darken(18%))[#q.number]
|
||||
#{
|
||||
let level = q.at("level", default: none)
|
||||
if level != none {
|
||||
linebreak()
|
||||
text(size: 0.62em, fill: luma(110))[L#level]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
#if show-question-map and questions.len() > 0 [
|
||||
= Question by question
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Numbers refer to your own copy of the exam. The questions themselves are not
|
||||
reproduced here.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#grid(
|
||||
columns: (1fr,) * 10,
|
||||
gutter: 4pt,
|
||||
..questions.map(question-box),
|
||||
)
|
||||
|
||||
#v(0.5em)
|
||||
#text(size: 0.78em, fill: luma(110))[
|
||||
#box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) correct ·
|
||||
#box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) partial ·
|
||||
#box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) incorrect ·
|
||||
#box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) blank
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Notes on what was missed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let missed = questions.filter(q =>
|
||||
q.at("credit", default: 0.0) < 0.999 and q.at("feedback", default: none) != none
|
||||
)
|
||||
|
||||
#if show-feedback and missed.len() > 0 [
|
||||
= Notes on the ones you missed
|
||||
|
||||
#text(size: 0.9em, fill: luma(95))[
|
||||
Each note is written for the specific option you chose, so it names the idea
|
||||
behind that answer rather than restating the right one.
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#for q in missed [
|
||||
#block(breakable: false, above: 0.7em, width: 100%)[
|
||||
#grid(
|
||||
columns: (2.6em, 1fr),
|
||||
gutter: 6pt,
|
||||
align(top)[#text(weight: "bold", fill: accent)[#q.number.]],
|
||||
[
|
||||
#markup(q.feedback)
|
||||
#{
|
||||
let taught = q.at("taught-in", default: ())
|
||||
if taught.len() > 0 [
|
||||
\ #text(size: 0.82em, fill: luma(110))[Covered in: #taught.join("; ")]
|
||||
]
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Footer note
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#v(1.2em)
|
||||
#line(length: 100%, stroke: 0.5pt + luma(210))
|
||||
#v(0.4em)
|
||||
#text(size: 0.76em, fill: luma(120))[
|
||||
Generated #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version
|
||||
from #cb-meta.at("class-size", default: 0) student(s). Percentages on a handful of
|
||||
questions carry wide uncertainty; bring this to office hours rather than reading
|
||||
it as a verdict.
|
||||
]
|
||||
Reference in New Issue
Block a user