Files
huayi-question-bank/scripts/select-bank-snapshots.cjs

154 lines
10 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// Select complete latest answer batches from untouched browser CSV exports.
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const core = require('../src/core.js');
const { EXPECTED_BANKS, nameKey, readExpectedCounts, readInput } = require('./merge-question-banks.cjs');
const expectedNames = new Map(EXPECTED_BANKS.map(name => [nameKey(name), name]));
const sha256 = value => crypto.createHash('sha256').update(value).digest('hex');
const expectedType = key => key.includes('判断题库') ? 'judge' : key.includes('单选题库') ? 'single' : 'multiple';
function parseArgs(argv) {
const options = { files: [], manifestPath: null, outDir: path.resolve(__dirname, '..', '题库'), force: false, help: false };
for (let index = 0; index < argv.length; index++) {
const value = argv[index];
if (value === '--') { options.files.push(...argv.slice(index + 1)); break; }
if (value === '--force') options.force = true;
else if (value === '--help' || value === '-h') options.help = true;
else if (value === '--manifest' || value === '--out-dir') {
const argument = argv[++index];
if (!argument || argument.startsWith('--')) throw new Error(`${value} 需要一个路径`);
if (value === '--manifest') options.manifestPath = path.resolve(argument);
else options.outDir = path.resolve(argument);
} else if (value.startsWith('--')) throw new Error(`不支持的参数:${value}`);
else options.files.push(value);
}
if (!options.help && !options.manifestPath) throw new Error('必须用 --manifest 指定五库页面题数核对清单');
if (!options.help && new Set(options.files.map(filename => path.resolve(filename))).size !== EXPECTED_BANKS.length) {
throw new Error('必须提供五个不同的按题库原始 CSV 文件,每个题库一个');
}
return options;
}
function batchSource(record) {
return record.answerSource?.batchId ? record.answerSource : record.source;
}
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 [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}`);
const batchTime = source.batchId.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z):\d+$/);
if (batchTime && Date.parse(batchTime[1]) !== time) throw new Error(`${bankName} 批次 ID 与标准答案观察时间不一致:${source.batchId}`);
if (!groups.has(source.batchId)) groups.set(source.batchId, { batchId: source.batchId, time, records: [] });
const group = groups.get(source.batchId);
if (group.time !== time) throw new Error(`${bankName} 同一批次出现多个标准答案观察时间:${source.batchId}`);
group.records.push(record);
}
if (!groups.size) throw new Error(`${bankName} 没有带明确批次的 network 标准答案,未回退历史记录`);
const ordered = [...groups.values()].sort((left, right) => right.time - left.time);
if (ordered.length > 1 && ordered[0].time === ordered[1].time) throw new Error(`${bankName} 最新时间对应多个批次,无法唯一确定最新组`);
const latest = ordered[0];
const rows = [...latest.records].sort((left, right) => left.questionIndex - right.questionIndex);
const fail = problem => { throw new Error(`${bankName} 最新批次 ${latest.batchId} 校验失败:${problem};不会回退较旧批次`); };
if (rows.length !== expectedCount) fail(`页面 ${expectedCount} 题,最新批次 ${rows.length} 条`);
if (rows.some((record, index) => record.questionIndex !== index)) fail('questionIndex 必须唯一覆盖 0..N-1');
if (rows.some(record => !record.questionId) || new Set(rows.map(record => record.questionId)).size !== rows.length) fail('题目 ID 为空或重复');
const bankIds = new Set(rows.map(record => record.bankId));
if (bankIds.size !== 1 || bankIds.has('')) fail('最新批次必须包含一个明确且一致的题库 ID');
if (rows.some(record => nameKey(record.bankName) !== key || record.type !== expectedType(key))) fail('题库名称或题型不符');
if (rows.some(record => record.answerStatus !== 'confirmed')) fail('存在缺失或冲突的标准答案');
if (rows.some(record => core.mapAnswers(record, record).status !== 'matched')) fail('标准答案不能安全映射到自身选项');
const selected = new Set(latest.records);
const excluded = input.records.filter(record => !selected.has(record));
return {
key, records: rows,
audit: {
bankName, bankId: [...bankIds][0], pageQuestionCount: expectedCount,
rawFile: input.source.filename, rawSha256: input.source.sha256, exportedRecords: input.records.length,
selectedBatchId: latest.batchId, selectedObservedAt: new Date(latest.time).toISOString(), selectedRecords: rows.length,
excludedRecords: excluded.length, excludedKeys: excluded.map(record => record.key)
}
};
}
function prepare(options) {
const expectedCounts = readExpectedCounts(options.manifestPath);
const filenames = options.files.map(filename => path.resolve(filename));
if (filenames.length !== EXPECTED_BANKS.length || new Set(filenames).size !== EXPECTED_BANKS.length) throw new Error('必须提供五个不同的原始 CSV 文件');
const inputs = filenames.map(filename => {
if (path.extname(filename).toLowerCase() !== '.csv') throw new Error(`只接受原始 CSV 文件:${path.basename(filename)}`);
return readInput(filename);
});
const selected = inputs.map(input => {
const key = nameKey(input.records[0].bankName);
return selectSnapshot(input, expectedCounts.get(key));
});
const byName = new Map(selected.map(bank => [bank.key, bank]));
if (byName.size !== EXPECTED_BANKS.length || EXPECTED_BANKS.some(name => !byName.has(nameKey(name)))) throw new Error('五个输入必须分别对应五个不同的指定题库');
const banks = EXPECTED_BANKS.map(name => byName.get(nameKey(name)));
const audit = {
schemaVersion: 1, selectedAt: new Date().toISOString(), validation: 'latest-network-batches-checked-against-page-manifest',
manifest: { filename: path.basename(options.manifestPath), sha256: sha256(fs.readFileSync(options.manifestPath)) },
exportedRecords: inputs.reduce((count, input) => count + input.records.length, 0),
selectedRecords: banks.reduce((count, bank) => count + bank.records.length, 0),
excludedRecords: banks.reduce((count, bank) => count + bank.audit.excludedRecords, 0),
banks: banks.map(bank => bank.audit)
};
const files = banks.map(bank => ({ name: path.join('分题库', `${bank.audit.bankName}_${bank.records.length}题.csv`), content: core.toCSV(bank.records) }));
files.push({ name: '导出批次核对.json', content: JSON.stringify(audit, null, 2) + '\n' });
return { files, audit, records: banks.flatMap(bank => bank.records) };
}
function writeOutput(options, prepared) {
const sourcePaths = new Set([...options.files, options.manifestPath].map(filename => fs.realpathSync(path.resolve(filename))));
const files = prepared.files.map(file => ({ ...file, target: path.join(path.resolve(options.outDir), file.name) }));
// Validate every exact destination before creating directories or writing anything.
for (const file of files) {
if (!fs.existsSync(file.target)) continue;
const stat = fs.lstatSync(file.target);
if (stat.isSymbolicLink() || !stat.isFile() || sourcePaths.has(fs.realpathSync(file.target))) throw new Error(`输出不能覆盖原始输入、链接或非文件:${file.target}`);
if (!options.force && fs.readFileSync(file.target, 'utf8') !== file.content) throw new Error(`输出已存在,未覆盖:${file.target};需要覆盖时使用 --force`);
}
for (const file of files) {
fs.mkdirSync(path.dirname(file.target), { recursive: true });
if (fs.existsSync(file.target) && fs.readFileSync(file.target, 'utf8') === file.content) continue;
fs.writeFileSync(file.target, file.content, { encoding: 'utf8', flag: options.force ? 'w' : 'wx' });
}
return files.map(file => file.target);
}
function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
process.stdout.write('用法:node scripts/select-bank-snapshots.cjs --manifest 题库/页面题数核对.json [--out-dir 题库] [--force] 五个按库原始CSV\n仅选择每库时间最新的明确 network 标准答案批次;最新组校验失败时不回退、不写输出。\n');
return;
}
const prepared = prepare(options);
const outputFiles = writeOutput(options, prepared);
process.stdout.write(JSON.stringify({ selectedRecords: prepared.audit.selectedRecords, excludedRecords: prepared.audit.excludedRecords,
banks: prepared.audit.banks.map(({ bankName, selectedRecords, excludedRecords }) => ({ bankName, selectedRecords, excludedRecords })), outputFiles }, null, 2) + '\n');
}
if (require.main === module) {
try { main(); }
catch (error) { process.stderr.write(`批次选择失败:${error.message}\n`); process.exitCode = 1; }
}
module.exports = { parseArgs, selectSnapshot, prepare, writeOutput, main };