WIP: Prelim use #2

Draft
alexm wants to merge 26 commits from prelim-use into main
4 changed files with 188 additions and 18 deletions
Showing only changes of commit 9755417899 - Show all commits
+22 -1
View File
@@ -676,6 +676,27 @@ pub(crate) enum ReportCommand {
/// Leave out the hint written for the option the student chose.
#[arg(long)]
no_hints: bool,
/// Leave the "kinds of thinking" comparison out of the Typst report.
#[arg(long)]
no_levels: bool,
/// Leave the per-objective mastery table out of the Typst report.
#[arg(long)]
no_objectives: bool,
/// Leave the objectives called out as strengths out of the Typst report.
#[arg(long)]
no_strengths: bool,
/// Leave the objectives called out as focus areas out of the Typst report.
#[arg(long)]
no_focus: bool,
/// Leave the dropped-question notice out of the Typst report.
#[arg(long)]
no_dropped_questions: bool,
/// Leave lecture-review suggestions out of the Typst report.
#[arg(long)]
no_review_lectures: bool,
/// Leave suggested study groups and readings out of the Typst report.
#[arg(long)]
no_study: bool,
/// Name the misconception each chosen distractor was written to catch.
///
/// Written for you rather than for them, so it reads clinically next to
@@ -778,4 +799,4 @@ mod tests {
fn the_cli_rejects_an_unknown_subcommand() {
assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err());
}
}
}
+33 -2
View File
@@ -498,6 +498,13 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
no_questions,
no_feedback,
no_hints,
no_levels,
no_objectives,
no_strengths,
no_focus,
no_dropped_questions,
no_review_lectures,
no_study,
misconceptions,
solutions,
template,
@@ -548,7 +555,31 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
let analysis =
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
let config = typst::load_config(&catalog.layout)?.resolve(Variant::StudentReport);
let mut config = typst::load_config(&catalog.layout)?.resolve(Variant::StudentReport);
// Each flag only turns a section off; leaving it unset keeps whatever
// templates/typst.yaml already resolved to, so a CLI run that doesn't
// mention a section never overrides a course's saved preference.
if *no_levels {
config.student_sections.levels = false;
}
if *no_objectives {
config.student_sections.objectives = false;
}
if *no_strengths {
config.student_sections.strengths = false;
}
if *no_focus {
config.student_sections.focus = false;
}
if *no_dropped_questions {
config.student_sections.dropped_questions = false;
}
if *no_review_lectures {
config.student_sections.review_lectures = false;
}
if *no_study {
config.student_sections.study = false;
}
let meta = typst_diagnostic::Meta::new(&catalog, &record, cohort.students.len());
let opts = diagnostic::Options {
comparison: !no_comparison,
@@ -751,4 +782,4 @@ fn warn_about_drift(
eprintln!(" {}", finding.message);
}
}
}
}
+79 -1
View File
@@ -328,6 +328,58 @@ impl Default for Fields {
}
}
/// Which sections of a student diagnostic are emitted.
///
/// Everything defaults on. Each flag corresponds to one block that
/// [`crate::typst::diagnostic::student_value`] would otherwise write
/// unconditionally: turning one off drops it from `cb-data` as an empty array
/// rather than omitting the key, so a template that checks `len() > 0` (the
/// pattern the bundled templates use) simply renders nothing for that section
/// without needing to guard against a missing key.
///
/// This governs whole sections. Which fields survive *within* a question —
/// feedback, hints, misconceptions, worked solutions — is decided when the
/// diagnostic itself is built, not here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StudentSections {
/// The per-level ("kinds of thinking") comparison to the class.
#[serde(default = "yes")]
pub levels: bool,
/// The per-objective mastery table.
#[serde(default = "yes")]
pub objectives: bool,
/// Objectives called out as strengths.
#[serde(default = "yes")]
pub strengths: bool,
/// Objectives called out as focus areas.
#[serde(default = "yes")]
pub focus: bool,
/// Which questions were dropped from scoring.
#[serde(default = "yes")]
pub dropped_questions: bool,
/// Lectures to revisit for missed objectives.
#[serde(default = "yes")]
pub review_lectures: bool,
/// Suggested study groups and their readings.
#[serde(default = "yes")]
pub study: bool,
}
impl Default for StudentSections {
fn default() -> StudentSections {
StudentSections {
levels: true,
objectives: true,
strengths: true,
focus: true,
dropped_questions: true,
review_lectures: true,
study: true,
}
}
}
/// The resolved configuration for rendering one variant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -378,6 +430,11 @@ pub struct RenderConfig {
#[serde(default = "yes")]
pub number_from_record: bool,
/// Which sections of a student diagnostic are emitted. Ignored by every
/// variant except [`Variant::StudentReport`].
#[serde(default)]
pub student_sections: StudentSections,
/// Anything else you want the template to see, carried through untouched.
///
/// This is the escape hatch that keeps the crate out of your layout
@@ -410,6 +467,7 @@ impl RenderConfig {
stimulus: StimulusMode::Inline,
fields: Fields::default(),
number_from_record: true,
student_sections: StudentSections::default(),
extra: BTreeMap::new(),
};
match variant {
@@ -560,6 +618,9 @@ pub struct Overrides {
/// See [`RenderConfig::fields`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Fields>,
/// See [`RenderConfig::student_sections`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub student_sections: Option<StudentSections>,
/// See [`RenderConfig::number_from_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number_from_record: Option<bool>,
@@ -603,6 +664,9 @@ impl Overrides {
if let Some(v) = &self.fields {
config.fields = v.clone();
}
if let Some(v) = &self.student_sections {
config.student_sections = v.clone();
}
if let Some(v) = self.number_from_record {
config.number_from_record = v;
}
@@ -668,6 +732,20 @@ variants:
answer-sheet:
reveal: nothing
# Every student-report section defaults on. Uncomment what you don't want;
# the CLI's `--no-levels`, `--no-objectives`, `--no-strengths`, `--no-focus`,
# `--no-dropped-questions`, `--no-review-lectures`, and `--no-study` flags
# set these same fields for a single run without editing this file.
# student-report:
# student_sections:
# levels: false
# objectives: false
# strengths: false
# focus: false
# dropped_questions: false
# review_lectures: false
# study: false
"#;
/// Serde default: `true`.
@@ -791,4 +869,4 @@ mod tests {
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
}
}
}
+54 -14
View File
@@ -169,10 +169,16 @@ fn extra_value(config: &RenderConfig) -> Value {
/// 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.
/// * `config` - the render config, for markup handling and section toggles.
///
/// # Returns
///
@@ -206,11 +212,15 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
out.insert("standing", value);
}
let levels = if config.student_sections.levels {
diagnostic.levels.as_slice()
} else {
&[]
};
out.insert(
"levels",
Value::Array(
diagnostic
.levels
levels
.iter()
.map(|level| {
let mut value = Value::dict();
@@ -227,11 +237,15 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
),
);
let objectives = if config.student_sections.objectives {
diagnostic.objectives.as_slice()
} else {
&[]
};
out.insert(
"objectives",
Value::Array(
diagnostic
.objectives
objectives
.iter()
.map(|objective| {
let mut value = Value::dict();
@@ -278,8 +292,22 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
.collect(),
)
};
out.insert("strengths", objective_refs(&diagnostic.strengths));
out.insert("focus", objective_refs(&diagnostic.focus));
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",
@@ -349,11 +377,15 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
),
);
let dropped_questions = if config.student_sections.dropped_questions {
diagnostic.dropped_questions.as_slice()
} else {
&[]
};
out.insert(
"dropped-questions",
Value::Array(
diagnostic
.dropped_questions
dropped_questions
.iter()
.map(|dropped| {
let mut value = Value::dict();
@@ -365,11 +397,15 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
),
);
let review_lectures = if config.student_sections.review_lectures {
diagnostic.review_lectures.as_slice()
} else {
&[]
};
out.insert(
"review-lectures",
Value::Array(
diagnostic
.review_lectures
review_lectures
.iter()
.map(|lecture| {
let mut value = Value::dict();
@@ -414,11 +450,15 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
),
);
let study = if config.student_sections.study {
diagnostic.study.as_slice()
} else {
&[]
};
out.insert(
"study",
Value::Array(
diagnostic
.study
study
.iter()
.map(|group| study_value(group, content))
.collect(),
@@ -1196,4 +1236,4 @@ mod tests {
assert!(text.contains("reliability"));
assert!(!text.contains("stem"));
}
}
}