diff --git a/src/cli.rs b/src/cli.rs index a96a667..ad7b520 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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()); } -} +} \ No newline at end of file diff --git a/src/commands/analysis.rs b/src/commands/analysis.rs index 68da7e6..213fc3b 100644 --- a/src/commands/analysis.rs +++ b/src/commands/analysis.rs @@ -498,6 +498,13 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result { 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 { 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); } } -} +} \ No newline at end of file diff --git a/src/export/typst/config.rs b/src/export/typst/config.rs index c99e5f8..bd02870 100644 --- a/src/export/typst/config.rs +++ b/src/export/typst/config.rs @@ -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, + /// See [`RenderConfig::student_sections`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub student_sections: Option, /// See [`RenderConfig::number_from_record`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub number_from_record: Option, @@ -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); } -} +} \ No newline at end of file diff --git a/src/export/typst/diagnostic.rs b/src/export/typst/diagnostic.rs index 47ee911..73ce262 100644 --- a/src/export/typst/diagnostic.rs +++ b/src/export/typst/diagnostic.rs @@ -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")); } -} +} \ No newline at end of file