// 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, /// The Typst source. pub text: String, /// Advisory problems. pub warnings: Vec, } /// 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, /// Assessment id. pub assessment_id: String, /// Assessment title. pub assessment_title: String, /// The administration date, as `YYYY-MM-DD`. pub date: Option, /// 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); // `students-tested` is the honest name: it is how many people sat this // assessment, which is not the enrolment. `class-size` stays as an alias // so a template forked before this change keeps working. out.insert("students-tested", Value::Int(self.n_students as i64)); 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. /// /// `config.student_sections` decides which of `levels`, `objectives`, /// `strengths`, `focus`, `dropped-questions`, `review-lectures`, and `study` /// are populated; a section turned off is emitted as an empty array rather /// than left out of the dictionary, so a template need not guard against a /// missing key. /// /// # Arguments /// /// * `diagnostic` - the assembled diagnostic. /// * `config` - the render config, for markup handling and section toggles. /// /// # 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("email", diagnostic.email.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); } let levels = if config.student_sections.levels { diagnostic.levels.as_slice() } else { &[] }; out.insert( "levels", Value::Array( 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(), ), ); let objectives = if config.student_sections.objectives { diagnostic.objectives.as_slice() } else { &[] }; out.insert( "objectives", Value::Array( 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(if config.student_sections.strengths { &diagnostic.strengths } else { &[] }), ); out.insert( "focus", objective_refs(if config.student_sections.focus { &diagnostic.focus } else { &[] }), ); 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( "targets", Value::Array(question.targets.iter().map(Value::str).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("dropped", Value::Bool(question.dropped)); value.insert("blank", Value::Bool(question.blank)); value.insert_some("class-rate", question.class_rate.map(Value::Float)); // Both tiers, as a list of pairs: the target says what this // question asked, the objective says which row of the table // above it counted toward. `objective` is absent when the // tagged id is an objective with no targets, so the template // does not print one sentence twice. value.insert( "measured", Value::Array( question .measured .iter() .map(|m| { let mut pair = Value::dict(); pair.insert_some( "objective", m.objective .as_ref() .map(|text| markup_value(text, content)), ); pair.insert("target", markup_value(&m.target, content)); pair }) .collect(), ), ); for (key, text) in [ ("feedback", question.feedback.as_ref()), ("hint", question.hint.as_ref()), ("misconception", question.misconception.as_ref()), ("worked", question.worked.as_ref()), ] { value.insert_some(key, text.map(|t| markup_value(t, content))); } value.insert( "taught-in", Value::Array(question.taught_in.iter().map(Value::str).collect()), ); value.insert( "review", Value::Array( question .review .iter() .map(|reading| { let mut entry = Value::dict(); entry.insert("citation", Value::str(&reading.citation)); entry.insert_some( "title", reading.title.as_ref().map(Value::str), ); entry.insert_some("url", reading.url.as_ref().map(Value::str)); entry }) .collect(), ), ); value }) .collect(), ), ); let dropped_questions = if config.student_sections.dropped_questions { diagnostic.dropped_questions.as_slice() } else { &[] }; out.insert( "dropped-questions", Value::Array( dropped_questions .iter() .map(|dropped| { let mut value = Value::dict(); value.insert("number", Value::Int(dropped.number as i64)); value.insert("full-credit", Value::Bool(dropped.full_credit)); value }) .collect(), ), ); let review_lectures = if config.student_sections.review_lectures { diagnostic.review_lectures.as_slice() } else { &[] }; out.insert( "review-lectures", Value::Array( review_lectures .iter() .map(|lecture| { let mut value = Value::dict(); value.insert("lecture", Value::str(&lecture.lecture)); value.insert("title", Value::str(&lecture.title)); value.insert_some("url", lecture.url.as_ref().map(Value::str)); value.insert("targets-missed", Value::Int(lecture.n_targets as i64)); value.insert("questions-missed", Value::Int(lecture.n_questions as i64)); value.insert( "questions", Value::Array( lecture .questions .iter() .map(|n| Value::Int(*n as i64)) .collect(), ), ); value.insert( "slides", Value::Array( lecture .slides .iter() .map(|n| Value::Int(*n as i64)) .collect(), ), ); value.insert( "targets", Value::Array( lecture .targets .iter() .map(|text| markup_value(text, content)) .collect(), ), ); value }) .collect(), ), ); let study = if config.student_sections.study { diagnostic.study.as_slice() } else { &[] }; out.insert( "study", Value::Array( 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(), ), ); // Separate from `questions` so no statistic can pick them up, and merged // back in by the evidence section, which describes rather than measures. out.insert( "dropped-detail", Value::Array( diagnostic .dropped_detail .iter() .map(|q| cohort_question_value(q, content)) .collect(), ), ); out.insert( "grades", Value::Array( diagnostic .grades .iter() .map(|grade| { let mut value = Value::dict(); value.insert("letter", Value::str(&grade.letter)); value.insert("low", Value::Float(grade.low)); value.insert("high", Value::Float(grade.high)); value.insert_some("gpa", grade.gpa.map(Value::Float)); value.insert_some("attainment", grade.attainment.as_ref().map(Value::str)); value.insert("group", Value::str(&grade.group)); value.insert("count", Value::Int(grade.count as i64)); value.insert("share", Value::Float(grade.share)); value.insert("at-or-above", Value::Int(grade.at_or_above as i64)); value }) .collect(), ), ); out.insert( "lectures", Value::Array( diagnostic .lectures .iter() .map(|lecture| { let mut value = Value::dict(); value.insert("lecture", Value::str(&lecture.lecture)); value.insert("title", Value::str(&lecture.title)); value.insert("items", Value::Int(lecture.n_items as i64)); value.insert("objectives", Value::Int(lecture.n_objectives as i64)); value.insert( "objectives-below", Value::Int(lecture.n_objectives_below as i64), ); value.insert("rate", Value::Float(lecture.rate)); value.insert( "questions", Value::Array( lecture .questions .iter() .map(|n| Value::Int(*n as i64)) .collect(), ), ); value.insert_some( "worst-objective", lecture .worst_objective .as_ref() .map(|text| markup_value(text, content)), ); value }) .collect(), ), ); out.insert( "dropped-questions", Value::Array( diagnostic .dropped_questions .iter() .map(|dropped| { let mut value = Value::dict(); value.insert("number", Value::Int(dropped.number as i64)); value.insert("full-credit", Value::Bool(dropped.full_credit)); value }) .collect(), ), ); let triage_rows = |rows: &[crate::diagnostic::TriageRow]| -> Value { Value::Array(rows.iter().map(|row| triage_value(row, content)).collect()) }; let mut triage = Value::dict(); triage.insert("discard", triage_rows(&diagnostic.triage.discard)); triage.insert("rekey", triage_rows(&diagnostic.triage.rekey)); triage.insert("revise", triage_rows(&diagnostic.triage.revise)); triage.insert("reteach", triage_rows(&diagnostic.triage.reteach)); triage.insert("bounded", triage_rows(&diagnostic.triage.bounded)); triage.insert("clean", Value::Int(diagnostic.triage.clean as i64)); out.insert("triage", triage); let predictions = &diagnostic.predictions; let mut prediction = Value::dict(); prediction.insert("predicted", Value::Int(predictions.n_predicted as i64)); prediction.insert("calibrated", Value::Int(predictions.n_calibrated as i64)); prediction.insert_some( "mean-signed-error", predictions.mean_signed_error.map(Value::Float), ); prediction.insert_some( "mean-abs-error", predictions.mean_abs_error.map(Value::Float), ); prediction.insert("within", Value::Int(predictions.n_within as i64)); prediction.insert("band", Value::Int(predictions.n_band as i64)); prediction.insert("band-hit", Value::Int(predictions.n_band_hit as i64)); if let Some((number, expected, observed)) = predictions.biggest_surprise { let mut surprise = Value::dict(); surprise.insert("number", Value::Int(number as i64)); surprise.insert("expected", Value::Float(expected)); surprise.insert("observed", Value::Float(observed)); prediction.insert("biggest-surprise", surprise); } out.insert("predictions", prediction); 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(Value::str).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(Value::str).collect()), ); out } /// One triage row as a Typst value. fn triage_value(row: &crate::diagnostic::TriageRow, content: bool) -> Value { let mut value = Value::dict(); value.insert("number", Value::Int(row.number as i64)); value.insert_some("item", row.item.as_ref().map(Value::str)); value.insert_some("level", row.level.map(|l| Value::Int(l as i64))); value.insert("p", Value::Float(row.p_value)); value.insert_some("point-biserial", row.point_biserial.map(Value::Float)); value.insert_some("discrimination", row.discrimination.map(Value::Float)); value.insert( "targets", Value::Array( row.targets .iter() .map(|text| markup_value(text, content)) .collect(), ), ); value.insert( "taught-in", Value::Array(row.taught_in.iter().map(Value::str).collect()), ); value.insert_some("option", row.option.as_ref().map(Value::str)); value.insert_some("option-share", row.option_share.map(Value::Float)); value.insert_some( "option-point-biserial", row.option_point_biserial.map(Value::Float), ); value.insert( "reasons", Value::Array( row.reasons .iter() .map(|reason| markup_value(reason, content)) .collect(), ), ); value } /// 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( "targets", Value::Array(question.targets.iter().map(Value::str).collect()), ); value.insert( "target-texts", Value::Array( question .target_texts .iter() .map(|text| markup_value(text, content)) .collect(), ), ); value.insert_some( "stem", question .stem .as_ref() .map(|text| markup_value(text, content)), ); value.insert("dropped", Value::Bool(question.dropped)); value.insert( "dropped-full-credit", Value::Bool(question.dropped_full_credit), ); value.insert( "taught-in", Value::Array(question.taught_in.iter().map(Value::str).collect()), ); value.insert( "lectures", Value::Array(question.lectures.iter().map(Value::str).collect()), ); value.insert("difficulty-band", Value::str(&question.difficulty_band)); value.insert( "discrimination-band", Value::str(&question.discrimination_band), ); 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(Value::str).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_some( "text", option.text.as_ref().map(|text| markup_value(text, content)), ); // The letter on each paper, so a statistic reported against // the bank letter can be checked against a student's copy. value.insert( "printed", Value::Array( option .printed .iter() .map(|printed| { let mut pair = Value::dict(); pair.insert("form", Value::str(&printed.form)); pair.insert("letter", Value::str(&printed.letter)); pair }) .collect(), ), ); 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(Value::str).collect()), ); value.insert( "notes", Value::Array( question .notes .iter() .map(|n| markup_value(n, content)) .collect(), ), ); value.insert( "prediction-notes", Value::Array( question .prediction_notes .iter() .map(|n| markup_value(n, content)) .collect(), ), ); value.insert("calibrated", Value::Bool(question.calibrated)); 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 { 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 { 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 { 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(); for (slot, body) in &bodies { if crate::markup::needs_chem_import(&template.source, body) { warnings.push(format!( "the `{}` slot carries a chemical formula, but the template {} does not import \ whalogen, so Typst will stop at `unknown variable: ce`; add `{}`", slot.as_str(), template.origin, crate::markup::CHEM_IMPORT )); } } 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 { 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 = 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(), grades: Vec::new(), lectures: Vec::new(), dropped_questions: Vec::new(), questions: Vec::new(), triage: crate::diagnostic::Triage::default(), predictions: crate::diagnostic::PredictionSummary::default(), revise: Vec::new(), forms: Vec::new(), blueprint: Vec::new(), patterns: Vec::new(), warnings: Vec::new(), dropped_detail: Vec::new(), } } #[test] fn report_markup_takes_the_same_path_as_a_paper() { for source in [ "$\\ce{H2O <=> H+ + OH-}$", "the backbone $\\ce{-C=O}$ group", "$K_w = [\\text{H}^+][\\text{OH}^-]$", "see @fig:x where x < y", "costs \\$5, and just $5", ] { let expected = crate::markup::to_typst(source); assert_eq!( markup_value(source, true).to_typst(0), format!("[{expected}]"), "content mode diverged on {source:?}" ); assert_eq!( markup_value(source, false).to_typst(0), Value::str(expected).to_typst(0), "string mode diverged on {source:?}" ); } } #[test] fn a_report_carrying_chemistry_names_a_template_missing_the_import() { let body = "#let cb-data = (stem: [#ce(\"H2O\")])"; assert!(crate::markup::needs_chem_import( "#import \"@preview/mitex:0.2.7\": mi\n// coursebank:data\n", body )); assert!(!crate::markup::needs_chem_import( crate::markup::CHEM_IMPORT, body )); } #[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")); } }