UNPKG

grading

Version:

Grading of student submissions, in particular programming tests.

371 lines 18.9 kB
"use strict"; 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