grading
Version:
Grading of student submissions, in particular programming tests.
371 lines • 18.9 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createLatexFragment = exports.cmdGrade = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const promises_1 = require("stream/promises");
const csv_1 = require("../csv");
const fsUtil_1 = require("../fsUtil");
const grading_1 = require("../grade/grading");
const gradingschema_1 = require("../grade/gradingschema");
const results_1 = require("../grade/results");
const results2csv_1 = require("../grade/results2csv");
const results2latex_1 = require("../grade/results2latex");
const statistics_1 = require("../grade/statistics");
const cliUtil_1 = require("./cliUtil");
const console_1 = require("console");
const table_1 = require("../table");
const cmdCompare_1 = require("./cmdCompare");
const ENCODING = "utf-8";
async function cmdGrade(moodleFile, options, runInternally = false) {
(0, cliUtil_1.verbosity)(options);
(0, cliUtil_1.log)("Grade all results at " + (0, fsUtil_1.timestamp)());
try {
moodleFile = await (0, cliUtil_1.retrieveOriginalMoodleFile)(moodleFile, options);
const reportsDir = options.reportsDir;
const submissionsDir = options.submissionsDir;
const schemaFile = options.gradingSchemaFile;
await (0, fsUtil_1.ensureFolderExists)(submissionsDir, "Check submission folder.");
await (0, fsUtil_1.ensureFolderExists)(reportsDir, "Check reports folder.");
await (0, fsUtil_1.ensureFileExists)(schemaFile, "Check grading schema file.");
const patchFolder = 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 (0, fsUtil_1.ensureFileExists)(moodleFile, "Check MoodleCSV setting.");
}
const submissionDirs = await (0, fsUtil_1.readDir)(submissionsDir, 'dir', false, 'Check submissions folder.');
let submitters = submissionDirs.map(subDir => (0, cliUtil_1.parseSubmitter)(subDir));
if (submitters.length == 0) {
program.error(`No submissions found in ${submissionsDir}`);
}
const gradingSchema = await (0, gradingschema_1.readGradingSchema)(schemaFile);
const manualCorrection = await readManualCorrections(gradingSchema, options);
checkManualCorrections(gradingSchema, submitters, manualCorrection, false);
let results = await (0, grading_1.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_1.default.relative(options.workDir, reportsDir);
await createLatexFragment(results, { latexFragmentFile: latexFragmentFile, dry: options.dry, workDir: options.workDir, reportsDir: reportsDir, patchFolder: patchFolder });
}
catch (err) {
(0, cliUtil_1.error)("Error creating LaTex fragment: " + err);
}
}
let resultFile = undefined;
if (!options.noResultCSV) {
try {
resultFile = await createResultCSV(gradingSchema, results, options);
}
catch (err) {
(0, cliUtil_1.error)("Error creating result CSV: " + err);
}
}
let moodleGradingTable = null;
if (!options.noMoodleCSV) {
try {
const readable = fs_1.default.createReadStream(moodleFile, "utf-8");
moodleGradingTable = await (0, csv_1.readCSV)(readable);
}
catch (err) {
(0, cliUtil_1.error)(`Error reading Moodle grading CSV '${moodleFile}'`);
(0, cliUtil_1.error)(err);
}
if (moodleGradingTable) {
await createGradingFile(moodleGradingTable, gradingSchema, results, options, moodleFile, []);
}
}
await statistics(submitters, gradingSchema, results, moodleGradingTable, options);
(0, cliUtil_1.verb)(`Compare results with previous results: ${options.compareResults}`);
if (options.compareResults && resultFile) {
await (0, cmdCompare_1.compareResults)(resultFile, options);
}
}
catch (err) {
(0, cliUtil_1.error)(`${SEP}\nError: ${err}`);
program.error(String(err));
}
if (!runInternally) {
(0, cliUtil_1.log)(`${SEP}\nDone.`);
}
}
exports.cmdGrade = cmdGrade;
async function statistics(submitters, gradingSchema, results, moodleGradingTable, options) {
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 = new Map();
for (let i = 0; i <= 15; i++) {
grades.set(results_1.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_1.Table();
if (moodleGradingTable) {
const participants = moodleGradingTable.rowsCount - 1;
logStatTab(table, TAB, "Participants", (0, cliUtil_1.withDecimals)(participants));
if (participants > 0) {
logStatTab(table, TAB, "Submission rate", (0, cliUtil_1.withDecimals)(submitters.length / participants));
}
else {
table.addRow("Submission rate", "na");
}
}
else {
table.addRow("Participants", "na");
table.addRow("Submission rate", "na");
}
logStatTab(table, TAB, "Submissions", (0, cliUtil_1.withDecimals)(submitters.length));
logStatTab(table, TAB, "Missing check test reports", (0, cliUtil_1.withDecimals)(submitters.length - sumCheckReports));
logStatTab(table, TAB, "Missing student test reports", (0, cliUtil_1.withDecimals)(submitters.length - sumStudentReports));
logStatTab(table, TAB, "Not graded successfully", (0, cliUtil_1.withDecimals)(submitters.length - sumGradingDone));
let gradeSum = 0;
let gradeCount = 0;
for (let i = 0; i < results_1.GRADE_VALUES.length; i++) {
const count = grades.get(results_1.GRADE_VALUES[i]) ?? -1;
gradeSum += count * Number.parseFloat(results_1.GRADE_VALUES[i].replace(',', '.'));
gradeCount += count;
}
if (gradeCount > 0) {
logStatTab(table, TAB, "Average grade", (0, cliUtil_1.withDecimals)(gradeSum / gradeCount));
logStatTab(table, TAB, "Failure rate", (0, cliUtil_1.withDecimals)((grades.get(results_1.GRADE_VALUES[0]) ?? -1) / gradeCount));
}
else {
table.addRow("Average grade", "na");
table.addRow("Failure rate", "na");
}
logStatTab(table, 5, "Grade", ...results_1.GRADE_VALUES);
logStatTab(table, 5, "from", ...results.gradingTable);
logStatTab(table, 5, "count", ...results_1.GRADE_VALUES.map(gv => grades.get(gv)));
const statistics = (0, statistics_1.computeStatistics)(gradingSchema, results);
logStatTab(table, TAB, "Average coverage", (0, cliUtil_1.withDecimals)(statistics.averageCoverage));
logStatTab(table, TAB, "Average student tests", (0, cliUtil_1.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, (0, cliUtil_1.withDecimals)(item.successRate));
}
else {
verbStatTab(table, 20, item.title, (0, cliUtil_1.withDecimals)(item.successRate));
}
}
if (!options.noStatisticsCSV) {
const fileName = (0, cliUtil_1.generateFileName)(options.statisticsFile, { examName: results.examName, dateTime: results.dateTime });
if (options.dry) {
(0, cliUtil_1.log)(`Would write statistics "${fileName}", skipped in dry mode.`);
return;
}
else {
(0, cliUtil_1.log)(`Write statistics "${fileName}".`);
}
await (0, csv_1.writeTableWithEncoding)(fileName, table, options.encoding, options.resultCSVDelimiter);
}
}
async function createLatexFragment(results, optionsLatexFragment) {
const latexString = (0, results2latex_1.toLatex)(results, optionsLatexFragment.workDir, optionsLatexFragment.reportsDir, optionsLatexFragment.patchFolder);
if (optionsLatexFragment.dry) {
(0, cliUtil_1.log)(`Would write latex fragment "${optionsLatexFragment.latexFragmentFile}", skipped in dry mode.`);
return;
}
(0, cliUtil_1.log)(`Write latex fragment "${optionsLatexFragment.latexFragmentFile}".`);
await fs_1.default.promises.writeFile(optionsLatexFragment.latexFragmentFile, latexString);
}
exports.createLatexFragment = createLatexFragment;
async function createResultCSV(gradingSchema, results, options) {
const fileName = (0, cliUtil_1.generateFileName)(options.resultFile, { examName: results.examName, dateTime: results.dateTime });
const table = (0, results2csv_1.toTable)(results, gradingSchema);
if (options.dry) {
(0, cliUtil_1.log)(`Would write result CSV file "${fileName}", skipped in dry mode.`);
return;
}
(0, cliUtil_1.log)(`Write result CSV "${fileName}".`);
await (0, csv_1.writeTableWithEncoding)(fileName, table, options.encoding, options.resultCSVDelimiter);
return fileName;
}
/**
* Writes Moodle Grading File (Bewertungstabelle...)
*/
async function createGradingFile(moodleGradingTable, gradingSchema, results, options, moodleFile, selected) {
const moodleFileBase = path_1.default.basename(moodleFile);
const fileName = (0, cliUtil_1.generateFileName)(options.gradedMoodleFile, {
examName: results.examName,
dateTime: results.dateTime,
moodleFile: moodleFileBase.substring(0, moodleFileBase.length - path_1.default.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';
(0, cliUtil_1.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) {
(0, console_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) {
(0, cliUtil_1.log)(`Would write Moodle grading file "${fileName}", skipped in dry mode.`);
return;
}
(0, cliUtil_1.log)(`Write Moodle grading file "${fileName}".`);
const writeStreamBew = fs_1.default.createWriteStream(fileName, { encoding: ENCODING });
const csvStreamBew = new csv_1.CSVProducer(moodleGradingTable, { delimiter: ",", escapeWhenSpace: true, encoding: ENCODING });
await (0, promises_1.pipeline)(csvStreamBew, writeStreamBew);
}
function percent(successRate) {
const n = Math.round(successRate * 1000);
if (n % 10 == 0) {
return (n / 10) + ".0%";
}
return (n / 10) + "%";
}
function logStatTab(table, pad, ...values) {
table.addRow(...values);
let out = "";
values.forEach((v, i) => {
if (i != 0) {
out += ": ";
}
out += (0, cliUtil_1.quickFormat)(v, pad);
});
(0, cliUtil_1.log)(out);
}
function verbStatTab(table, pad, ...values) {
table.addRow(...values);
let out = "";
values.forEach((v, i) => {
if (i != 0) {
out += ": ";
}
out += (0, cliUtil_1.quickFormat)(v, pad);
});
(0, cliUtil_1.verb)(out);
}
async function readManualCorrections(gradingSchema, options) {
if (!options.manualCorrectionsFile) {
return;
}
const correctionFile = (0, cliUtil_1.generateFileName)(options.manualCorrectionsFile, { stdInFolder: options.stdInFolder });
if (!await (0, fsUtil_1.fileExists)(correctionFile, 'correctionFile')) {
(0, cliUtil_1.verb)(`No manual correction file "${correctionFile}" found, skipped.`);
return undefined;
}
const correctionSchema = await (0, cliUtil_1.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) {
(0, console_1.warn)(`Deprecated userID found in manual correction file "${correctionFile}", please use submissionID instead.`);
}
return correctionSchema;
}
function checkManualCorrections(gradingSchema, submissions, manualCorrection, selectionOnly) {
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) {
const n = Number.parseFloat(grade.replace(',', '.'));
if (Number.isNaN(n)) {
throw new Error(`Invalid grade "${grade}"`);
}
return n;
}
//# sourceMappingURL=cmdGrade.js.map
;