refactor: improve student report

This commit is contained in:
2026-09-19 21:30:04 -04:00
parent 994e9065e8
commit c06d5caa8c
5 changed files with 1074 additions and 270 deletions
+341 -15
View File
@@ -23,9 +23,21 @@
//! exam is given. //! exam is given.
//! //!
//! What a student does get, per missed question: the number, its level, the //! What a student does get, per missed question: the number, its level, the
//! objectives it measured, whether they answered it, and the feedback written for //! objectives it measured and what those objectives ask, whether they answered
//! the specific option they chose. That last part is why authoring distractors //! it, the feedback written for the specific option they chose, the hint written
//! carefully pays off twice. //! for that same option, and the item's own `review` citations. That is why
//! authoring distractors carefully pays off twice.
//!
//! Two further fields are available and off by default, because they are the two
//! that trade a student's understanding against reusing the question. The
//! misconception is written to you about the student; the worked solution is the
//! solutions document. See [`Options::misconceptions`] and [`Options::solutions`].
//!
//! It is worth being clear about what the default already discloses. The
//! per-option feedback on a missed question routinely names the right answer,
//! because that is what makes it useful. A report handed to sixty students is
//! therefore already a partial answer key for the questions those students
//! missed, with or without the two optional fields.
//! //!
//! Two consequences of that invariant are worth stating because they are easy to //! Two consequences of that invariant are worth stating because they are easy to
//! undo by accident: //! undo by accident:
@@ -46,6 +58,11 @@
//! resolves each weak objective through the course's own reading registry, so a //! resolves each weak objective through the course's own reading registry, so a
//! student is pointed at `KKW §6.2` with the sentence you wrote about what to take //! student is pointed at `KKW §6.2` with the sentence you wrote about what to take
//! from it, rather than at the name of a chapter. //! from it, rather than at the name of a chapter.
//!
//! [`LectureFocus`] answers the question a student actually asks, which is where
//! to start. It ranks the lectures behind the missed questions by how many
//! objectives went wrong in each, so a reading list of eleven sections becomes an
//! ordered afternoon.
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
@@ -54,8 +71,9 @@ use serde::Serialize;
use crate::assessment::AssessmentFile; use crate::assessment::AssessmentFile;
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::classical::Analysis; use crate::classical::Analysis;
use crate::course::{CourseFile, ReadingRole}; use crate::course::{CourseFile, ReadingRole, Reference};
use crate::irt::Fit; use crate::irt::Fit;
use crate::item::Citation;
use crate::responses::{Response, ResponseSet}; use crate::responses::{Response, ResponseSet};
use crate::students::{Cohort, Mastery, StudentSummary}; use crate::students::{Cohort, Mastery, StudentSummary};
use crate::taxonomy::Level; use crate::taxonomy::Level;
@@ -72,6 +90,27 @@ pub struct Options {
pub questions: bool, pub questions: bool,
/// Whether to include the feedback written for the option the student chose. /// Whether to include the feedback written for the option the student chose.
pub feedback: bool, pub feedback: bool,
/// Whether to include the hint written for that option.
///
/// On by default. A hint is the question you would ask a student who was
/// reconsidering that option, so it gives them somewhere to start rather than
/// a verdict to accept.
pub hints: bool,
/// Whether to name the misconception the chosen distractor was written to
/// catch.
///
/// Off by default, and not because it is unsafe. The text is written to you,
/// about the student, in the third person, and next to the feedback written
/// for them it reads like a chart note.
pub misconceptions: bool,
/// Whether to include the worked solution for a missed question.
///
/// Off by default. [`crate::item::Solution::explanation`] is the derivation,
/// the estimate, and the argument for the key over its neighbours: the body of
/// the solutions document. Turning this on hands that to every student who
/// missed the question, which is the right call for a question you will not
/// use again and the wrong one for a bank you reuse each term.
pub solutions: bool,
/// How many objectives to build a study plan for. /// How many objectives to build a study plan for.
pub focus_limit: usize, pub focus_limit: usize,
/// How many readings to list per objective. /// How many readings to list per objective.
@@ -86,6 +125,9 @@ impl Default for Options {
comparison: true, comparison: true,
questions: true, questions: true,
feedback: true, feedback: true,
hints: true,
misconceptions: false,
solutions: false,
focus_limit: 4, focus_limit: 4,
readings_per_objective: 2, readings_per_objective: 2,
ability: false, ability: false,
@@ -105,6 +147,12 @@ pub struct StudentDiagnostic {
/// The institutional id, when identifiers were kept. /// The institutional id, when identifiers were kept.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub sid: Option<String>, pub sid: Option<String>,
/// Their email, when the export carried one and identifiers were kept.
///
/// Absent rather than blank when the platform did not report it, so a
/// template prints nothing instead of an empty label.
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
/// Which form they sat. /// Which form they sat.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub form: Option<String>, pub form: Option<String>,
@@ -124,6 +172,10 @@ pub struct StudentDiagnostic {
/// One row per question, with no question in it. /// One row per question, with no question in it.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<QuestionRow>, pub questions: Vec<QuestionRow>,
/// Which lectures to go back to, the one that would repay the most time
/// first.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub review_lectures: Vec<LectureFocus>,
/// What to read, grouped by objective. /// What to read, grouped by objective.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub study: Vec<StudyGroup>, pub study: Vec<StudyGroup>,
@@ -253,6 +305,13 @@ pub struct QuestionRow {
pub level: Option<u8>, pub level: Option<u8>,
/// The objectives it measured. /// The objectives it measured.
pub objectives: Vec<String>, pub objectives: Vec<String>,
/// What those objectives ask, in the words the course uses with students.
///
/// The objective, not the question. It is printed in full in the objectives
/// table already; repeating it next to a missed question saves a student
/// working out which of thirty-six rows this one belonged to.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub objective_texts: Vec<String>,
/// Whether it was answered correctly. /// Whether it was answered correctly.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub correct: Option<bool>, pub correct: Option<bool>,
@@ -269,9 +328,68 @@ pub struct QuestionRow {
/// The feedback written for the option this student chose. /// The feedback written for the option this student chose.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub feedback: Option<String>, pub feedback: Option<String>,
/// The hint written for that option: where to look, not what the answer was.
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
/// The misconception that option was written to catch, when
/// [`Options::misconceptions`] is on.
#[serde(skip_serializing_if = "Option::is_none")]
pub misconception: Option<String>,
/// The worked solution, when [`Options::solutions`] is on.
#[serde(skip_serializing_if = "Option::is_none")]
pub worked: Option<String>,
/// Where the material was taught. /// Where the material was taught.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub taught_in: Vec<String>, pub taught_in: Vec<String>,
/// What to read again about this question, from the item's own `review`
/// citations rather than from the objective's reading list.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub review: Vec<ItemReading>,
}
/// One citation to read again after missing a question.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct ItemReading {
/// A short citation, e.g. `KKW §2.1`.
pub citation: String,
/// The work's full title, for a student who does not recognise the label.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// A link, when the citation resolves to one.
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
/// One lecture worth going back to, with the evidence for saying so.
///
/// Ranked by how many *objectives* went wrong rather than how many questions
/// did. Missing four questions on one objective is one thing to relearn; missing
/// four questions across four objectives is four, and the second is the lecture
/// to reread first even though the arithmetic looks identical.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct LectureFocus {
/// The lecture id, e.g. `L1.4`.
pub lecture: String,
/// Its title.
pub title: String,
/// Where the slides live, when the course records that.
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// How many distinct objectives from this lecture were missed.
pub n_objectives: usize,
/// How many questions from this lecture were missed.
pub n_questions: usize,
/// Which questions, so a student can line this up with their paper.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<u32>,
/// The slides those questions came from, when the items record them.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub slides: Vec<u32>,
/// The objectives that went wrong here, in the course's own words.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub objectives: Vec<String>,
} }
/// What to read about one objective. /// What to read about one objective.
@@ -338,6 +456,15 @@ pub fn student(
let rows = set.for_student(&summary.student_key); let rows = set.for_student(&summary.student_key);
let form = rows.first().and_then(|r| r.form.clone()); let form = rows.first().and_then(|r| r.form.clone());
// The summary carries the name; the email only ever existed on the response
// rows, and both are already absent from a pseudonymized store, so neither
// needs a flag here.
let name = summary
.name
.clone()
.or_else(|| rows.iter().find_map(|r| r.name.clone()));
let email = rows.iter().find_map(|r| r.email.clone());
let levels = summary let levels = summary
.levels .levels
.iter() .iter()
@@ -403,6 +530,11 @@ pub fn student(
Vec::new() Vec::new()
}; };
// Built from the response rows rather than from `questions`, so a report with
// `--no-questions` still says where to go back to; it just does not name the
// question numbers.
let review_lectures = lecture_focus(catalog, &rows, opts);
let study = focus let study = focus
.iter() .iter()
.take(opts.focus_limit) .take(opts.focus_limit)
@@ -417,8 +549,9 @@ pub fn student(
StudentDiagnostic { StudentDiagnostic {
student_key: summary.student_key.clone(), student_key: summary.student_key.clone(),
name: summary.name.clone(), name,
sid: summary.sid.clone(), sid: summary.sid.clone(),
email,
form, form,
score: Score { score: Score {
points: summary.points, points: summary.points,
@@ -440,6 +573,7 @@ pub fn student(
strengths, strengths,
focus, focus,
questions, questions,
review_lectures,
study, study,
} }
} }
@@ -457,22 +591,45 @@ fn question_row(
opts: &Options, opts: &Options,
) -> QuestionRow { ) -> QuestionRow {
let blank = row.selected.is_empty() && row.eliminated.is_empty(); let blank = row.selected.is_empty() && row.eliminated.is_empty();
let missed = row.credit < 0.999;
let mut feedback = None; let mut feedback = None;
let mut hint = None;
let mut misconception = None;
let mut worked = None;
let mut taught_in = Vec::new(); let mut taught_in = Vec::new();
let mut review = Vec::new();
if let Some(uid) = row.item_ref.as_deref() { if let Some(uid) = row.item_ref.as_deref() {
if let Some(entry) = catalog.get(uid) { if let Some(entry) = catalog.get(uid) {
if opts.feedback && row.credit < 0.999 { if missed {
if let Some(letter) = row.chosen().first() { if let Some(letter) = row.chosen().first() {
// `student_text` falls back to `explanation`, which is written if let Some(choice) = entry.item.option(letter) {
// for a grader and often names the right answer. A student if opts.feedback {
// report must not print it. // `student_text` falls back to `explanation`, which is
feedback = entry.item.option(letter).and_then(|choice| { // written for a grader and often names the right
choice // answer. A student report must not print it.
.feedback_student feedback = choice
.clone() .feedback_student
.or_else(|| choice.misconception.clone()) .clone()
}); .or_else(|| choice.misconception.clone());
}
if opts.hints {
hint = choice.hint.clone();
}
// When an option carries no student feedback, the
// fallback above already printed this text. The same
// sentence twice under two labels reads as a bug.
if opts.misconceptions && choice.misconception != feedback {
misconception = choice.misconception.clone();
}
}
}
if let Some(solution) = entry.item.solution.as_ref() {
if opts.solutions {
worked = solution.explanation.clone();
}
review = item_readings(&catalog.course, &solution.review);
} }
} }
for source in &entry.item.sources { for source in &entry.item.sources {
@@ -503,6 +660,16 @@ fn question_row(
position: row.form_position.filter(|p| *p != row.item_number), position: row.form_position.filter(|p| *p != row.item_number),
level: row.level.map(|l| l.code()), level: row.level.map(|l| l.code()),
objectives: row.learning_objectives.clone(), objectives: row.learning_objectives.clone(),
objective_texts: if missed {
row.learning_objectives
.iter()
.map(|id| catalog.course.objective_text(id))
.collect()
} else {
// Only where it earns its space. Every question already carries its
// objective ids, and the objectives table prints all of them.
Vec::new()
},
correct: row.correct, correct: row.correct,
credit: row.credit, credit: row.credit,
bonus: row.bonus, bonus: row.bonus,
@@ -512,10 +679,167 @@ fn question_row(
.then(|| class_rates.get(&row.item_number).copied()) .then(|| class_rates.get(&row.item_number).copied())
.flatten(), .flatten(),
feedback, feedback,
hint,
misconception,
worked,
taught_in, taught_in,
review,
} }
} }
/// Resolves an item's `review` citations against the course reference registry.
///
/// # Arguments
///
/// * `course` - the course, for its reference labels and base URLs.
/// * `citations` - the item's citations.
///
/// # Returns
///
/// One entry per citation that resolves to something printable.
fn item_readings(course: &CourseFile, citations: &[Citation]) -> Vec<ItemReading> {
let mut out = Vec::new();
for citation in citations {
let reference = citation
.reference
.as_deref()
.and_then(|key| course.references.get(key).map(|r| (key, r)));
let (label, title, url) = match reference {
Some((key, reference)) => (
reference.label.as_deref().unwrap_or(key).to_string(),
Some(reference.title.clone()),
resolve_citation_url(citation, reference),
),
None => (citation.display(), None, citation.url.clone()),
};
if label.is_empty() {
continue;
}
let citation_text = match (&citation.text, &citation.locator) {
(Some(text), _) => text.clone(),
(None, Some(locator)) => format!("{label} {locator}"),
(None, None) => label,
};
out.push(ItemReading {
citation: citation_text,
title,
url,
});
}
out
}
/// A citation's own URL, else the reference's `base_url` joined with its `path`.
fn resolve_citation_url(citation: &Citation, reference: &Reference) -> Option<String> {
if let Some(url) = &citation.url {
return Some(url.clone());
}
let path = citation.path.as_deref()?;
let base = reference.base_url.as_deref()?;
Some(match (base.ends_with('/'), path.starts_with('/')) {
(true, true) => format!("{base}{}", &path[1..]),
(false, false) => format!("{base}/{path}"),
_ => format!("{base}{path}"),
})
}
/// Ranks the lectures behind a student's missed questions.
///
/// A lecture earns its place by how many distinct objectives went wrong in it,
/// then by how many questions, then by id so the order is stable between runs.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for objective and lecture titles.
/// * `rows` - this student's responses.
/// * `opts` - what to include; `questions` decides whether numbers are named.
///
/// # Returns
///
/// The lectures, the one that would repay the most time first.
fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<LectureFocus> {
/// What has accumulated for one lecture so far.
#[derive(Default)]
struct Tally {
objectives: BTreeSet<String>,
questions: BTreeSet<u32>,
slides: BTreeSet<u32>,
}
let course = &catalog.course;
let mut tallies: BTreeMap<String, Tally> = BTreeMap::new();
for row in rows.iter().filter(|r| r.counts() && r.credit < 0.999) {
// Two routes to a lecture, and both are wanted. The objective registry
// knows which lectures develop an objective; the item knows which lecture
// it was written from, which is the finer answer when an objective spans
// several.
let mut lectures: BTreeSet<String> = BTreeSet::new();
let mut slides: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
if let Some(entry) = row.item_ref.as_deref().and_then(|uid| catalog.get(uid)) {
for source in &entry.item.sources {
lectures.insert(source.lecture.clone());
slides
.entry(source.lecture.clone())
.or_default()
.extend(source.slides.iter().copied());
}
}
for objective in &row.learning_objectives {
if let Some(entry) = course.learning_objectives.get(objective) {
lectures.extend(entry.lectures.iter().cloned());
}
}
for lecture in lectures {
let tally = tallies.entry(lecture.clone()).or_default();
tally.objectives.extend(row.learning_objectives.clone());
tally.questions.insert(row.item_number);
if let Some(numbers) = slides.get(&lecture) {
tally.slides.extend(numbers.iter().copied());
}
}
}
let mut out: Vec<LectureFocus> = tallies
.into_iter()
.map(|(lecture, tally)| {
let record = course.lectures.get(&lecture);
LectureFocus {
title: record
.map(|l| l.title.clone())
.unwrap_or_else(|| lecture.clone()),
url: record.and_then(|l| l.slides_url.clone()),
n_objectives: tally.objectives.len(),
n_questions: tally.questions.len(),
questions: if opts.questions {
tally.questions.iter().copied().collect()
} else {
Vec::new()
},
slides: tally.slides.iter().copied().collect(),
objectives: tally
.objectives
.iter()
.map(|id| course.objective_text(id))
.collect(),
lecture,
}
})
.collect();
out.sort_by(|a, b| {
b.n_objectives
.cmp(&a.n_objectives)
.then(b.n_questions.cmp(&a.n_questions))
.then(a.lecture.cmp(&b.lecture))
});
out
}
/// Resolves an objective to readings. /// Resolves an objective to readings.
/// ///
/// # Arguments /// # Arguments
@@ -1139,6 +1463,7 @@ mod tests {
student_key: "s-1".into(), student_key: "s-1".into(),
name: None, name: None,
sid: None, sid: None,
email: None,
form: None, form: None,
score: Score { score: Score {
points: 1.0, points: 1.0,
@@ -1154,6 +1479,7 @@ mod tests {
strengths: Vec::new(), strengths: Vec::new(),
focus: Vec::new(), focus: Vec::new(),
questions: Vec::new(), questions: Vec::new(),
review_lectures: Vec::new(),
study: Vec::new(), study: Vec::new(),
}) })
.unwrap(); .unwrap();
+15
View File
@@ -673,6 +673,21 @@ pub(crate) enum ReportCommand {
/// Leave per-option feedback out of the Typst report. /// Leave per-option feedback out of the Typst report.
#[arg(long)] #[arg(long)]
no_feedback: bool, no_feedback: bool,
/// Leave out the hint written for the option the student chose.
#[arg(long)]
no_hints: bool,
/// Name the misconception each chosen distractor was written to catch.
///
/// Written for you rather than for them, so it reads clinically next to
/// the feedback. Off by default for that reason, not for secrecy.
#[arg(long)]
misconceptions: bool,
/// Include the worked solution for every missed question.
///
/// This is the solutions document. Reasonable for a question you will
/// not use again, and a way to publish your bank if you reuse it.
#[arg(long)]
solutions: bool,
/// Use this template instead of the usual lookup. /// Use this template instead of the usual lookup.
#[arg(long)] #[arg(long)]
template: Option<PathBuf>, template: Option<PathBuf>,
+17
View File
@@ -497,6 +497,9 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
no_markdown, no_markdown,
no_questions, no_questions,
no_feedback, no_feedback,
no_hints,
misconceptions,
solutions,
template, template,
json, json,
} => { } => {
@@ -551,10 +554,24 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
comparison: !no_comparison, comparison: !no_comparison,
questions: !no_questions, questions: !no_questions,
feedback: !no_feedback, feedback: !no_feedback,
hints: !no_hints,
misconceptions: *misconceptions,
solutions: *solutions,
ability: *ability, ability: *ability,
..diagnostic::Options::default() ..diagnostic::Options::default()
}; };
// Said once, at the moment it would matter, rather than left in the
// help text where nobody reads it twice.
if *solutions && !cli.quiet {
println!(
"! --solutions puts the worked solution for every missed question into \
{} report(s). Reusing these items next term means reusing them against \
students who may have seen this page",
cohort.students.len()
);
}
let mut written = 0usize; let mut written = 0usize;
let mut used_embedded = false; let mut used_embedded = false;
for summary in &cohort.students { for summary in &cohort.students {
+90 -6
View File
@@ -148,6 +148,10 @@ impl Meta {
out.insert("assessment", assessment); out.insert("assessment", assessment);
out.insert("generator", generator); out.insert("generator", generator);
out.insert("policy", policy); 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("class-size", Value::Int(self.n_students as i64));
out.insert("extra", extra_value(config)); out.insert("extra", extra_value(config));
out out
@@ -180,6 +184,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
out.insert("student-key", Value::str(&diagnostic.student_key)); out.insert("student-key", Value::str(&diagnostic.student_key));
out.insert_some("name", diagnostic.name.as_ref().map(Value::str)); 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("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)); out.insert_some("form", diagnostic.form.as_ref().map(Value::str));
let mut score = Value::dict(); let mut score = Value::dict();
@@ -296,17 +301,96 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert("bonus", Value::Bool(question.bonus)); value.insert("bonus", Value::Bool(question.bonus));
value.insert("blank", Value::Bool(question.blank)); value.insert("blank", Value::Bool(question.blank));
value.insert_some("class-rate", question.class_rate.map(Value::Float)); value.insert_some("class-rate", question.class_rate.map(Value::Float));
value.insert_some( value.insert(
"feedback", "objective-texts",
question Value::Array(
.feedback question
.as_ref() .objective_texts
.map(|text| markup_value(text, content)), .iter()
.map(|text| markup_value(text, content))
.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( value.insert(
"taught-in", "taught-in",
Value::Array(question.taught_in.iter().map(|s| Value::str(s)).collect()), Value::Array(question.taught_in.iter().map(|s| Value::str(s)).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(),
),
);
out.insert(
"review-lectures",
Value::Array(
diagnostic
.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("objectives-missed", Value::Int(lecture.n_objectives 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(
"objectives",
Value::Array(
lecture
.objectives
.iter()
.map(|text| markup_value(text, content))
.collect(),
),
);
value value
}) })
.collect(), .collect(),
File diff suppressed because it is too large Load Diff