fix: require evidence for judgment answers and correct legacy banks

This commit is contained in:
suanyuit-cyber
2026-10-30 03:34:38 +08:00
parent 4bf2de1bad
commit 31a2640adb
21 changed files with 4875 additions and 11110 deletions
+26 -8
View File
@@ -67,16 +67,29 @@ function readInput(filename) {
if (!stat.isFile() || stat.size > 100 * 1024 * 1024) throw new Error(`输入不是文件或超过 100 MB:${path.basename(filename)}`);
const text = fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, '');
const extension = path.extname(filename).toLowerCase();
let records;
if (extension === '.csv') records = core.fromCSV(text);
let rows;
if (extension === '.csv') rows = core.fromCSV(text);
else if (extension === '.json') {
const payload = JSON.parse(text);
const rows = Array.isArray(payload) ? payload : payload?.schemaVersion === 1 ? payload.records : null;
rows = Array.isArray(payload) ? payload : payload?.schemaVersion === 1 ? payload.records : null;
if (!Array.isArray(rows)) throw new Error(`JSON 需要 schemaVersion: 1 和 records 数组:${path.basename(filename)}`);
records = rows.map(core.normalizeRecord);
} else throw new Error(`只接受 CSV 或 JSON 导出文件:${path.basename(filename)}`);
// Keep collection/answer batch provenance separately for fail-closed snapshot
// selection: repairing an untrusted answer may intentionally clear answerSource.
const answerBatchSources = rows.map(record => {
const source = record?.answerSource?.batchId ? record.answerSource : record?.source;
return source && typeof source === 'object' && !Array.isArray(source) ? { ...source } : null;
});
// JSON may not have been normalized yet. CSV's complete-record JSON retains
// the same provenance through fromCSV. Repair both before and after the
// normalization boundary so old list defaults cannot become standard answers.
const before = core.repairLegacyJudgeAnswers(rows);
const after = core.repairLegacyJudgeAnswers(before.records.map(core.normalizeRecord));
const repairedIndexes = new Set([...before.changed, ...after.changed].map(item => item.index));
const records = after.records;
if (!records.length) throw new Error(`导出文件没有题目:${path.basename(filename)}`);
return { records, source: { filename: path.basename(filename), sha256: sha256(fs.readFileSync(absolute)), inputRecords: records.length } };
return { records, answerBatchSources, source: { filename: path.basename(filename), sha256: sha256(fs.readFileSync(absolute)), inputRecords: records.length,
legacyJudgeAnswersRepaired: repairedIndexes.size } };
}
function prepare(options) {
@@ -100,6 +113,7 @@ function prepare(options) {
if (expectedType && rows.some(record => record.type !== expectedType)) problems.push(`题型与来源名称不一致:${rows[0].bankName}`);
if (expectedCounts.has(key) && rows.length !== expectedCounts.get(key)) problems.push(`题数不符:${rows[0].bankName},页面 ${expectedCounts.get(key)} 题,实际 ${rows.length} 题`);
}
const questionProblems = [...problems];
const missing = compacted.records.filter(record => record.answerStatus === 'missing').length;
const conflicts = compacted.records.filter(record => record.answerStatus === 'conflict').length;
const invalidAnswers = compacted.records.filter(record => record.answerStatus === 'confirmed' && core.mapAnswers(record, record).status !== 'matched').length;
@@ -117,13 +131,16 @@ function prepare(options) {
return a.stem.localeCompare(b.stem, 'zh-CN') || (a.contentOccurrence || 0) - (b.contentOccurrence || 0) || a.key.localeCompare(b.key);
}));
const now = new Date().toISOString();
const questionsComplete = questionProblems.length === 0 && expectedCounts.size === EXPECTED_BANKS.length;
const answersComplete = questionsComplete && missing === 0 && conflicts === 0 && invalidAnswers === 0;
const summary = {
schemaVersion: 1, exportedAt: now, sourceFiles: inputs.map(input => input.source),
complete: !problems.length && expectedCounts.size === EXPECTED_BANKS.length,
complete: questionsComplete && answersComplete, questionsComplete, answersComplete,
validation: problems.length ? 'partial' : 'passed-data-checks',
sourceCountValidation: expectedCounts.size ? 'checked-against-page-manifest' : 'not-provided',
totalQuestions: records.length, confirmedAnswers: records.filter(record => record.answerStatus === 'confirmed').length,
missingAnswers: missing, conflictingAnswers: conflicts, invalidAnswers,
legacyJudgeAnswersRepaired: inputs.reduce((count, input) => count + input.source.legacyJudgeAnswersRepaired, 0),
duplicateRowsRemoved: inputs.reduce((count, input) => count + input.records.length, 0) - records.length,
banks: orderedBanks.map(key => ({
bankName: expectedNames.get(key) || groups.get(key)[0].bankName,
@@ -173,7 +190,8 @@ function help() {
`--allow-partial 明确允许输出未收齐、缺答案或有冲突的数据,清单标记为 partial\n` +
`--force 明确允许覆盖本次指定的已有输出文件\n` +
`--help 显示帮助\n\n` +
`默认核对五个题库的名称、题型和标准答案。没有页面题数清单时,不声称源数据已完整收齐。\n`;
`默认核对五个题库的名称、题型和标准答案;旧练习列表中没有独立证据的判断答案会恢复为待补。\n` +
`清单分别报告 questionsComplete(题目收齐)与 answersComplete(标准答案齐全)。没有页面题数清单时,不声称源数据已完整收齐。\n`;
}
function main(argv = process.argv.slice(2)) {
@@ -182,7 +200,7 @@ function main(argv = process.argv.slice(2)) {
const prepared = prepare(options);
const outputFiles = writeOutput(options, prepared);
process.stdout.write(JSON.stringify({ ...prepared.summary, outputFiles }, null, 2) + '\n');
if (!prepared.summary.complete) process.stderr.write('注意:源数据完整性尚未通过五库页面题数核验,详见题库清单.json。\n');
if (!prepared.summary.complete) process.stderr.write(`注意:题目完整=${prepared.summary.questionsComplete},标准答案完整=${prepared.summary.answersComplete};详见题库清单.json。\n`);
}
if (require.main === module) {