feat: improve worksheet

This commit is contained in:
2026-08-10 01:52:15 -04:00
parent f475c630e0
commit 327ac371e4
17 changed files with 1469 additions and 49 deletions
+78 -4
View File
@@ -571,6 +571,77 @@ fn asset_schema() -> Value {
})
}
/// The worked solution, and for an open-response item how it is graded.
fn solution_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"description": "The answer, the reasoning, and the rubric. Rendered in the solutions \
document and the answer key, never in a question paper.",
"properties": {
"model_answer": {
"type": "string",
"description": "For an open-response item, the response a full-credit student \
writes; for a choice item, an optional one-line statement of the key."
},
"explanation": {
"type": "string",
"description": "The worked reasoning a student learns from. The body of the \
solutions entry."
},
"rubric": { "type": "array", "items": rubric_criterion_schema() },
"accepted": {
"type": "array",
"items": { "type": "string" },
"description": "Responses a short constructed answer is accepted as."
},
"review": {
"type": "array",
"items": citation_schema(),
"description": "Where to look again after missing this item."
}
}
})
}
/// One rubric line for an open-response item.
fn rubric_criterion_schema() -> Value {
json!({
"type": "object",
"required": ["description"],
"additionalProperties": false,
"properties": {
"description": text("What earns the points on this line."),
"points": { "type": "number", "minimum": 0.0 }
}
})
}
/// A citation into the reference registry, written as an object or a bare string.
fn citation_schema() -> Value {
json!({
"oneOf": [
{ "type": "string", "description": "A citation, unparsed." },
citation_mapping_schema()
]
})
}
/// The object form of a citation.
fn citation_mapping_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"ref": text("Citation key into `references`."),
"locator": { "type": "string", "description": "Where inside the work: §6.1, pp. 4-9." },
"path": { "type": "string", "description": "Joined to the reference base_url." },
"url": { "type": "string" },
"text": { "type": "string" }
}
})
}
/// The schema for authored design intent.
fn design_schema() -> Value {
json!({
@@ -738,9 +809,10 @@ fn item_identity_properties() -> Value {
"cognitive_process": cognitive_process(),
"format": {
"type": "string",
"enum": ["single_best_answer", "multiple_response", "true_false"],
"description": "single_best_answer requires exactly one keyed option; \
multiple_response requires at least two."
"enum": ["single_best_answer", "multiple_response", "true_false", "open_response"],
"description": "single_best_answer keys exactly one option; multiple_response keys \
two or more; open_response takes no options and is graded from its \
solution."
},
"bonus": { "type": "boolean" },
"points": { "type": "number", "exclusiveMinimum": 0.0 },
@@ -767,8 +839,10 @@ fn item_content_properties() -> Value {
"type": "array",
"minItems": 2,
"maxItems": 8,
"description": "Absent for an open_response item; at least two for any choice format.",
"items": option_schema()
},
"solution": solution_schema(),
"learning_objectives": string_array(
"Objective ids this item measures. Reports aggregate on these, so an item with none \
contributes to nothing."
@@ -807,7 +881,7 @@ fn item_schema() -> Value {
}
json!({
"type": "object",
"required": ["id", "level", "stem", "options"],
"required": ["id", "level", "stem"],
"additionalProperties": false,
"properties": Value::Object(properties)
})
+10 -4
View File
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
});
};
// ---------------------------------------------------------------- clarity
// --- clarity
let stem = it.stem.trim();
let stem_lower = stem.to_lowercase();
let words: Vec<&str> = stem.split_whitespace().collect();
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
);
}
// ----------------------------------------------------------------- cueing
// --- cueing
let keys: Vec<&crate::item::Choice> = it.options.iter().filter(|o| o.correct).collect();
let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect();
@@ -659,7 +659,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
}
}
// ----------------------------------------------------------- completeness
// --- completeness
// These are only worth insisting on once an item is meant to be used.
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
if is_ready {
@@ -749,7 +749,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
}
}
// --------------------------------------------------------------- evidence
// --- evidence
if !it.calibration_is_current() {
push(
Rule::StaleCalibration,
@@ -868,6 +868,12 @@ fn lint_option_counts(catalog: &Catalog) -> Vec<Finding> {
if e.item.status == Status::Retired {
continue;
}
// An open-response item carries no options, so it is neither part of the
// count norm nor able to deviate from it. Leaving it out keeps a bank of
// four-option questions from reporting every essay as an odd count.
if !e.item.has_options() {
continue;
}
by_bank.entry(e.bank.as_str()).or_default().push(e);
}
+4 -4
View File
@@ -84,7 +84,7 @@ pub fn select(
let seed = blueprint.seed.unwrap_or(0);
let mut notes = Vec::new();
// ------------------------------------------------------------------ pool
// --- pool
let eligible: Vec<&crate::catalog::Entry> = catalog
.assemblable()
.into_iter()
@@ -101,7 +101,7 @@ pub fn select(
let mut chosen: Vec<String> = Vec::new();
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
// ---------------------------------------------------- objective minimums
// --- objective minimums
// Placed first, because a coverage requirement is the constraint most likely
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
@@ -140,7 +140,7 @@ pub fn select(
}
}
// ------------------------------------------------------- level quotas
// --- level quotas
let mut scored: Vec<String> = Vec::new();
for (level, want) in &blueprint.level_counts {
if *want == 0 {
@@ -175,7 +175,7 @@ pub fn select(
}
}
// ------------------------------------------------------------ bonus items
// --- bonus items
let mut bonus: Vec<String> = Vec::new();
for (level, want) in &blueprint.bonus_counts {
if *want == 0 {