fix: require evidence for judgment answers and correct legacy banks
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -39,12 +39,17 @@ function batchSource(record) {
|
||||
}
|
||||
|
||||
function selectSnapshot(input, expectedCount) {
|
||||
const batchSources = input.answerBatchSources || input.records.map(batchSource);
|
||||
if (!Array.isArray(batchSources) || batchSources.length !== input.records.length) throw new Error('原始标准答案批次证据与题目数量不一致');
|
||||
// Keep this public pure entry point fail-closed as well as the file loader.
|
||||
// Legacy list judgeAnswer 0/1 defaults are not proof of a standard answer.
|
||||
input = { ...input, records: core.repairLegacyJudgeAnswers(input.records).records };
|
||||
const names = new Set(input.records.map(record => nameKey(record.bankName)));
|
||||
if (names.size !== 1 || !expectedNames.has([...names][0])) throw new Error(`原始 CSV 必须只包含一个指定题库:${input.source.filename}`);
|
||||
const key = [...names][0], bankName = expectedNames.get(key);
|
||||
const groups = new Map();
|
||||
for (const record of input.records) {
|
||||
const source = batchSource(record);
|
||||
for (const [index, record] of input.records.entries()) {
|
||||
const source = batchSources[index]?.batchId ? batchSources[index] : batchSource(record);
|
||||
if (source?.kind !== 'network' || !source.batchId) continue;
|
||||
const time = Date.parse(source.observedAt);
|
||||
if (!Number.isFinite(time)) throw new Error(`${bankName} 批次缺少有效标准答案观察时间:${source.batchId}`);
|
||||
|
||||
Reference in New Issue
Block a user