grading
Version:
Grading of student submissions, in particular programming tests.
425 lines (361 loc) • 18.4 kB
text/typescript
import { OptionValues } from "commander";
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { CSVProducer, readCSV, writeTableWithEncoding } from "../csv";
import { ensureFileExists, ensureFolderExists, fileExists, readDir, timestamp } from "../fsUtil";
import { doGrading } from "../grade/grading";
import { GradingSchema, readGradingSchema } from "../grade/gradingschema";
import { GRADE_VALUES, Results } from "../grade/results";
import { toTable } from "../grade/results2csv";
import { toLatex } from "../grade/results2latex";
import { computeStatistics } from "../grade/statistics";
import { error, generateFileName, JSONSrcMap, log, parseJsonWithComments, parseSubmitter, quickFormat, retrieveOriginalMoodleFile, verb, verbosity, withDecimals } from "./cliUtil";
import { warn } from "console";
import { CorrectionSchema } from "../grade/correctionschema";
import { Table } from "../table";
import { compareResults } from "./cmdCompare";
import { Submitter } from "./submitter";
const ENCODING: BufferEncoding = "utf-8";
export async function cmdGrade(moodleFile: string, options: OptionValues, runInternally = false) {
verbosity(options);
log("Grade all results at " + timestamp());
try {
moodleFile = await retrieveOriginalMoodleFile(moodleFile, options);
const reportsDir: string = options.reportsDir;
const submissionsDir: string = options.submissionsDir;
const schemaFile = options.gradingSchemaFile;
await ensureFolderExists(submissionsDir, "Check submission folder.");
await ensureFolderExists(reportsDir, "Check reports folder.");
await ensureFileExists(schemaFile, "Check grading schema file.");
const patchFolder: string = options.patchFolder;
if (moodleFile.length == 0) {
moodleFile == null;
}
if (!options.noMoodleCSV) {
if (!moodleFile) {
program.error("No Moodle input CSV file provided, cannot create output grading file.")
}
await ensureFileExists(moodleFile, "Check MoodleCSV setting.");
}
const submissionDirs = await readDir(submissionsDir, 'dir', false, 'Check submissions folder.');
let submitters = submissionDirs.map(subDir => parseSubmitter(subDir));
if (submitters.length == 0) {
program.error(`No submissions found in ${submissionsDir}`)
}
const gradingSchema = await readGradingSchema(schemaFile);
const manualCorrection = await readManualCorrections(gradingSchema, options);
checkManualCorrections(gradingSchema, submitters, manualCorrection, false);
let results: Results = await doGrading({
gradingSchema, manualCorrection, submitters,
reportsDir, startMarker: options.testOutputStartMarker, endMarker: options.testOutputEndMarker, patchFolder
});
if (!options.noLatex) {
const latexFragmentFile = options.latexFragmentFile;
try {
if (!options.latexFragmentFile) {
program.error("Missing option --latexFragmentFile");
}
const workToReportRelPath = path.relative(options.workDir, reportsDir);
await createLatexFragment(results,
{ latexFragmentFile: latexFragmentFile, dry: options.dry, workDir: options.workDir, reportsDir: reportsDir, patchFolder: patchFolder });
} catch (err) {
error("Error creating LaTex fragment: " + err);
}
}
let resultFile = undefined;
if (!options.noResultCSV) {
try {
resultFile = await createResultCSV(gradingSchema, results, options);
} catch (err) {
error("Error creating result CSV: " + err);
}
}
let moodleGradingTable: Table | null = null;
if (!options.noMoodleCSV) {
try {
const readable = fs.createReadStream(moodleFile, "utf-8");
moodleGradingTable = await readCSV(readable);
} catch (err) {
error(`Error reading Moodle grading CSV '${moodleFile}'`);
error(err);
}
if (moodleGradingTable) {
await createGradingFile(moodleGradingTable, gradingSchema, results, options, moodleFile, []);
}
}
await statistics(submitters, gradingSchema, results, moodleGradingTable, options);
verb(`Compare results with previous results: ${options.compareResults}`);
if (options.compareResults && resultFile) {
await compareResults(resultFile, options);
}
} catch (err) {
error(`${SEP}\nError: ${err}`);
program.error(String(err));
}
if (!runInternally) {
log(`${SEP}\nDone.`);
}
}
async function statistics(submitters: Submitter[], gradingSchema: GradingSchema, results: Results, moodleGradingTable: Table | null, options: OptionValues) {
const sumCheckReports = results.studentResults.reduce((sum, studentResult) => sum + (studentResult.checkReportFound ? 1 : 0), 0)
const sumStudentReports = results.studentResults.reduce((sum, studentResult) => sum + (studentResult.studentReportFound ? 1 : 0), 0)
const sumGradingDone = results.studentResults.reduce((sum, studentResult) => sum + (studentResult.gradingDone ? 1 : 0), 0)
const grades: Map<string, number> = new Map();
for (let i = 0; i <= 15; i++) { grades.set(GRADE_VALUES[i], 0); }
results.studentResults.forEach(studentResult => {
if (!studentResult.noSubmission) {
let count = grades.get(studentResult.grade);
if (count !== undefined) {
count++;
grades.set(studentResult.grade, count);
}
}
});
const TAB = 25;
const table = new Table();
if (moodleGradingTable) {
const participants = moodleGradingTable.rowsCount - 1;
logStatTab(table, TAB, "Participants", withDecimals(participants));
if (participants > 0) {
logStatTab(table, TAB, "Submission rate", withDecimals(submitters.length / participants));
} else {
table.addRow("Submission rate", "na");
}
} else {
table.addRow("Participants", "na");
table.addRow("Submission rate", "na");
}
logStatTab(table, TAB, "Submissions", withDecimals(submitters.length));
logStatTab(table, TAB, "Missing check test reports", withDecimals(submitters.length - sumCheckReports));
logStatTab(table, TAB, "Missing student test reports", withDecimals(submitters.length - sumStudentReports));
logStatTab(table, TAB, "Not graded successfully", withDecimals(submitters.length - sumGradingDone));
let gradeSum = 0;
let gradeCount = 0;
for (let i = 0; i < GRADE_VALUES.length; i++) {
const count = grades.get(GRADE_VALUES[i]) ?? -1;
gradeSum += count * Number.parseFloat(GRADE_VALUES[i].replace(',', '.'));
gradeCount += count;
}
if (gradeCount > 0) {
logStatTab(table, TAB, "Average grade", withDecimals(gradeSum / gradeCount));
logStatTab(table, TAB, "Failure rate", withDecimals((grades.get(GRADE_VALUES[0]) ?? -1) / gradeCount));
} else {
table.addRow("Average grade", "na");
table.addRow("Failure rate", "na");
}
logStatTab(table, 5, "Grade", ...GRADE_VALUES);
logStatTab(table, 5, "from", ...results.gradingTable);
logStatTab(table, 5, "count", ...GRADE_VALUES.map(gv => grades.get(gv)));
const statistics = computeStatistics(gradingSchema, results);
logStatTab(table, TAB, "Average coverage", withDecimals(statistics.averageCoverage));
logStatTab(table, TAB, "Average student tests", withDecimals(statistics.averageStudentTests));
logStatTab(table, TAB, "Applied corrections", statistics.appliedCorrections);
logStatTab(table, 20, "Label", "Success Rate")
for (const item of statistics) {
if (item.itemType === 'exam' || item.itemType === 'task') {
logStatTab(table, 20, item.title, withDecimals(item.successRate));
} else {
verbStatTab(table, 20, item.title, withDecimals(item.successRate));
}
}
if (!options.noStatisticsCSV) {
const fileName = generateFileName(options.statisticsFile, { examName: results.examName, dateTime: results.dateTime });
if (options.dry) {
log(`Would write statistics "${fileName}", skipped in dry mode.`);
return;
} else {
log(`Write statistics "${fileName}".`);
}
await writeTableWithEncoding(fileName, table, options.encoding, options.resultCSVDelimiter);
}
}
export async function createLatexFragment(results: Results,
optionsLatexFragment: {
latexFragmentFile: string, dry: boolean,
workDir: string, reportsDir: string,
patchFolder: string
}) {
const latexString = toLatex(results, optionsLatexFragment.workDir, optionsLatexFragment.reportsDir, optionsLatexFragment.patchFolder);
if (optionsLatexFragment.dry) {
log(`Would write latex fragment "${optionsLatexFragment.latexFragmentFile}", skipped in dry mode.`);
return;
}
log(`Write latex fragment "${optionsLatexFragment.latexFragmentFile}".`);
await fs.promises.writeFile(optionsLatexFragment.latexFragmentFile, latexString);
}
async function createResultCSV(gradingSchema: GradingSchema, results: Results, options: OptionValues) {
const fileName = generateFileName(options.resultFile, { examName: results.examName, dateTime: results.dateTime });
const table = toTable(results, gradingSchema);
if (options.dry) {
log(`Would write result CSV file "${fileName}", skipped in dry mode.`);
return;
}
log(`Write result CSV "${fileName}".`);
await writeTableWithEncoding(fileName, table, options.encoding, options.resultCSVDelimiter);
return fileName;
}
/**
* Writes Moodle Grading File (Bewertungstabelle...)
*/
async function createGradingFile(moodleGradingTable: Table, gradingSchema: GradingSchema, results: Results, options: OptionValues, moodleFile: string, selected: string[]) {
const moodleFileBase = path.basename(moodleFile);
const fileName = generateFileName(options.gradedMoodleFile,
{
examName: results.examName,
dateTime: results.dateTime,
moodleFile: moodleFileBase.substring(0, moodleFileBase.length - path.extname(moodleFileBase).length),
selected: selected
},
true);
// we need to work on original table, otherwise Moodle does not accept changes
// const outTable = new Table(moodleGradingTable);
if (options.gradingValue !== "grade" && options.gradingValue !== "points") {
throw new Error(`Invalid grading value "${options.gradingValue}", must be either grade or points.`);
}
const useGrade = options.gradingValue === 'grade';
verb(`Use ${useGrade ? 'grade' : 'points'} for grading in Moodle result table.`)
results.studentResults.forEach(studentResult => {
if (!studentResult.noSubmission && (selected?.length === 0 || selected.includes(studentResult.submissionId))) {
const row = moodleGradingTable.findRowWhere(row => {
const longID = moodleGradingTable.at("ID", row);
if (longID === "Teilnehmer/in" + studentResult.submissionId) { // first column in Moodle file
return true;
}
return false;
});
if (row < 1) {
warn(`Submission ${studentResult.submissionId} (${studentResult.userName}) not found.`);
} else {
// outTable.addRowFromTable(moodleGradingTable, row); // copy row
if (useGrade) {
moodleGradingTable.setAt("Bewertung", row, gradeToNumber(studentResult.grade));
} else { // use points
moodleGradingTable.setAt("Bewertung", row,
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2 }).format(studentResult.points));
}
const date = new Date();
const dateString = date.toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
moodleGradingTable.setAt("Zuletzt geändert (Bewertung)", row, dateString);
}
}
});
if (options.dry) {
log(`Would write Moodle grading file "${fileName}", skipped in dry mode.`);
return;
}
log(`Write Moodle grading file "${fileName}".`);
const writeStreamBew = fs.createWriteStream(fileName, { encoding: ENCODING });
const csvStreamBew = new CSVProducer(moodleGradingTable, { delimiter: ",", escapeWhenSpace: true, encoding: ENCODING });
await pipeline(csvStreamBew, writeStreamBew);
}
function percent(successRate: number) {
const n = Math.round(successRate * 1000)
if (n % 10 == 0) {
return (n / 10) + ".0%";
}
return (n / 10) + "%";
}
function logStatTab(table: Table, pad: number, ...values: any[]) {
table.addRow(...values)
let out = "";
values.forEach((v, i) => {
if (i != 0) {
out += ": ";
}
out += quickFormat(v, pad);
});
log(out);
}
function verbStatTab(table: Table, pad: number, ...values: any[]) {
table.addRow(...values)
let out = "";
values.forEach((v, i) => {
if (i != 0) {
out += ": ";
}
out += quickFormat(v, pad);
});
verb(out);
}
async function readManualCorrections(gradingSchema: GradingSchema, options: OptionValues) {
if (!options.manualCorrectionsFile) {
return;
}
const correctionFile = generateFileName(options.manualCorrectionsFile, { stdInFolder: options.stdInFolder });
if (! await fileExists(correctionFile, 'correctionFile')) {
verb(`No manual correction file "${correctionFile}" found, skipped.`);
return undefined;
}
const correctionSchema: CorrectionSchema = await parseJsonWithComments(correctionFile);
if (gradingSchema.exam !== correctionSchema.exam) {
throw new Error(`Exam name "${gradingSchema.exam}" does not match correction exam name "${correctionSchema.exam}".`);
}
if (gradingSchema.course !== correctionSchema.course) {
throw new Error(`Course name "${gradingSchema.course}" does not match correction course name "${correctionSchema.course}".`);
}
if (gradingSchema.term !== correctionSchema.term) {
throw new Error(`Term name "${gradingSchema.term}" does not match correction term name "${correctionSchema.term}".`);
}
let deprecatedUserIDFound = false;
correctionSchema.corrections.forEach(correction => {
if (!correction.submissionID && "userID" in correction && typeof correction.userID === "string") { // fix old format
correction.submissionID = correction.userID;
deprecatedUserIDFound = true;
}
if (correction.tasks) {
correction.tasks = correction.tasks.filter(task => task.points || task.reason);
}
if (correction.general) {
correction.general = correction.general.filter(task => task.points || task.reason); // filter out empty lines
correction.general.forEach(general => { if (!general.absolute) { general.absolute = false; } }); // explicitly set boolean value
if (correction.general.filter(general => general.absolute).length > 1) {
throw new Error(`Only one absolute correction allowed per student, found more for ${correction.submissionID} ${correction.userName}.`);
}
}
});
correctionSchema.corrections = correctionSchema.corrections.filter(correction =>
(correction.tasks?.length && correction.tasks?.length > 0)
||
(correction.general?.length && correction.general?.length > 0)
);
if (deprecatedUserIDFound) {
warn(`Deprecated userID found in manual correction file "${correctionFile}", please use submissionID instead.`);
}
return correctionSchema;
}
function checkManualCorrections(gradingSchema: GradingSchema, submissions: Submitter[], manualCorrection: CorrectionSchema | undefined, selectionOnly: boolean) {
if (!manualCorrection) {
return;
}
manualCorrection.corrections.forEach(correction => {
const submission = submissions.find(sub => sub.submissionId === correction.submissionID);
if (!submission && !selectionOnly) {
const possibleID = submissions.find(sub => sub.name === correction.userName)?.submissionId || undefined;
throw new Error(`Cannot apply manual correction: No submission with ID ${correction.submissionID} (${correction.userName}) found in submissions${possibleID ? ". Did you mean " + possibleID : " Neither found student name in submissions."}.`);
}
if (submission) {
if (!submissions.find(sub => sub.name === correction.userName)) {
throw new Error(`Cannot apply manual correction: Submission with ID ${correction.submissionID} found, but names are different: Did you mean ${submission.name} instead of ${correction.userName}?`);
}
if (submission.name !== correction.userName) {
const possibleID = submissions.find(sub => sub.name === correction.userName)?.submissionId || undefined;
throw new Error(`Error in manual correction: Student name and id do not match. Did you mean ${submission.name}?`
+ (possibleID ? ` Student ${correction.userName} has ID ${possibleID}.` : ""));
}
correction.tasks?.forEach(task => {
const gradingTask = gradingSchema.tasks.find(t => t.name === task.name);
if (!gradingTask) {
throw new Error(`Error in manual correction: No task with name ${task.name} found in grading schema.`);
}
});
}
});
}
function gradeToNumber(grade: string): number {
const n = Number.parseFloat(grade.replace(',', '.'));
if (Number.isNaN(n)) {
throw new Error(`Invalid grade "${grade}"`);
}
return n;
}