feat: add unified learning workflows and six-tab assistant

This commit is contained in:
薇星幻海
2026-10-18 01:11:33 +08:00
parent 20abb33740
commit eb9c610b9f
32 changed files with 23806 additions and 353 deletions
+46 -16
View File
@@ -1,4 +1,4 @@
/* Merge each role's CSV files without losing course-specific question records. */
/* Build one bank with every original source cell and validate its import identity. */
'use strict';
const fs = require('node:fs');
const path = require('node:path');
@@ -32,10 +32,7 @@ function csv(rows) {
return '\uFEFF' + rows.map(row => row.map(cell).join(',')).join('\r\n') + '\r\n';
}
function comparable(records) {
// sourceFile changes to the merged filename on CSV import; all answer data must remain identical.
return Array.from(records, ({ sourceFile, ...record }) => JSON.stringify(record)).sort();
}
function comparable(records) { return Array.from(records, record => JSON.stringify(record)).sort(); }
async function main() {
const inputPath = path.resolve(process.argv[2] || path.join(rootDir, '题库.zip'));
@@ -48,6 +45,7 @@ async function main() {
if (!entries.length) throw new Error('ZIP 内没有 CSV 题库');
const parser = runtime();
const groups = new Map(roles.map(role => [role, { files: [], rows: [] }]));
const originalCells = [];
const headers = ['岗位', '来源文件', ...originalHeaders];
for (const entry of entries) {
const role = entry.name.replace(/\\/g, '/').split('/').find(part => roles.includes(part));
@@ -62,35 +60,67 @@ async function main() {
group.files.push({ name: entry.name, total: rows.length - 1 });
for (const [index, row] of rows.slice(1).entries()) {
if (row.length !== sourceHeaders.length) throw new Error(entry.name + ' 第 ' + (index + 2) + ' 条记录列数不匹配');
group.rows.push({ 岗位: role, 来源文件: entry.name, ...Object.fromEntries(sourceHeaders.map((name, column) => [name, row[column]])) });
const cells = { 岗位: role, 来源文件: entry.name, ...Object.fromEntries(sourceHeaders.map((name, column) => [name, row[column]])) };
group.rows.push(cells);
originalCells.push({ role, sourceFile: entry.name, sourceHeaders, cells: row.slice() });
}
}
// Use the assistant's actual import validator to compare the source ZIP with all three results.
// Keep the previous three files compatible, but recommend the single unified output.
const original = await runtime().importFiles([file(path.basename(inputPath), input)]);
const outputs = roles.map(role => {
const compatibilityOutputs = roles.map(role => {
const group = groups.get(role);
if (!group.rows.length) throw new Error('缺少岗位题库:' + role);
return { role, name: role + '_合并题库.csv', bytes: Buffer.from(csv([headers, ...group.rows.map(row => headers.map(name => row[name] ?? ''))])) };
});
const merged = await runtime().importFiles(outputs.map(output => file(output.name, output.bytes)));
const unifiedRows = roles.flatMap(role => groups.get(role).rows);
const unified = { role: '', name: '统一题库.csv', bytes: Buffer.from(csv([headers, ...unifiedRows.map(row => headers.map(name => row[name] ?? ''))])) };
const outputs = [unified, ...compatibilityOutputs];
const generatedRows = parser.parseCSV(decode(unified.bytes));
const generatedHeaders = generatedRows[0];
const generatedBySource = new Map();
generatedRows.slice(1).forEach(row => {
assert.equal(row.length, generatedHeaders.length, '生成 CSV 的列数不一致');
const role = row[generatedHeaders.indexOf('岗位')], sourceFile = row[generatedHeaders.indexOf('来源文件')];
const key = JSON.stringify([role, sourceFile]);
if (!generatedBySource.has(key)) generatedBySource.set(key, []);
generatedBySource.get(key).push(row);
});
const sourceOffsets = new Map();
for (const originalRow of originalCells) {
const key = JSON.stringify([originalRow.role, originalRow.sourceFile]);
const offset = sourceOffsets.get(key) || 0;
const generatedRow = generatedBySource.get(key)?.[offset];
assert(generatedRow, '生成 CSV 缺少来源行:' + originalRow.sourceFile);
originalRow.sourceHeaders.forEach((name, column) => {
assert.equal(generatedRow[generatedHeaders.indexOf(name)], originalRow.cells[column], originalRow.sourceFile + ' 原字段改变:' + name + ' 第 ' + (offset + 1) + ' 行');
});
sourceOffsets.set(key, offset + 1);
}
const merged = await runtime().importFiles([file(unified.name, unified.bytes)]);
const sourceRowCount = roles.reduce((total, role) => total + groups.get(role).rows.length, 0);
assert.equal(merged.total, sourceRowCount, '合并后导入丢失了题目记录');
const originalRecords = comparable(original.records);
assert(comparable(merged.records).every((record, index) => record === originalRecords[index]), '合并前后题干、选项、答案或课程来源发生变化');
assert.deepEqual(comparable(merged.records), originalRecords, '合并前后题干、选项、答案、原岗位或课程来源发生变化');
const compatible = await runtime().importFiles(compatibilityOutputs.map(output => file(output.name, output.bytes)));
assert.deepEqual(comparable(compatible.records), originalRecords, '兼容文件与统一文件的数据不一致');
const report = [
'# 按岗位合并题库', '',
'由原始 `题库.zip` 中各岗位的 CSV 合并生成。三个文件均可在“谊安学助手 2.0”的“导入题库”中直接选择,也可同时导入;文件内已有岗位字段,无需靠当前岗位选择归类。', '',
'# 统一题库', '',
'推荐下载并导入 [统一题库.csv](统一题库.csv)。由原始 `题库.zip` 的 28 个 CSV 合成,共 5520 条来源记录,主操、副操、班长共用检索和答案匹配,不再需要选择岗位。原岗位仅作为来源信息保留。', '',
'导入后的相同题型、题干、完整选项及答案会聚合展示,并保留全部来源;不同答案分别展示并标记冲突,自动答题不会多数决。可以通过明确的课程/来源信息消歧。', '',
'统一题库共有 ' + merged.uniqueTotal + ' 个题目答案组合、' + merged.questionTotal + ' 道唯一题目,其中 ' + merged.conflictTotal + ' 道存在跨来源答案冲突。', '',
'## 兼容文件', '',
'下面三个旧文件继续保留,内容均已包含在统一题库中,无需另外导入。', '',
'| 文件 | 原文件数 | 记录数 | 单选 | 多选 | 判断 |',
'|---|---:|---:|---:|---:|---:|'
];
for (const output of outputs) {
for (const output of compatibilityOutputs) {
const records = merged.records.filter(record => record.role === output.role);
const count = type => records.filter(record => record.type === type).length;
report.push('| [' + output.name + '](' + output.name + ') | ' + groups.get(output.role).files.length + ' | ' + records.length + ' | ' + count('单选') + ' | ' + count('多选') + ' | ' + count('判断') + ' |');
}
report.push('', '合计 ' + merged.total + ' 条记录。统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。', '',
report.push('', '统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。', '',
'合并保留全部记录,不按题干去重,避免丢失课程来源或不同选项。同题在不同课程出现答案差异时也原样保留;搜索时可按课程核对。“常压罐组收料作业中,以下哪些属于工程控制措施?”在风险管理题库中为 ABC,在重大危险源题库中为 ABCD。', '',
'负数等原有防公式单引号转义保持不变,助手导入时会按原规则恢复选项文字。', '',
'## 来源文件', '', '| 岗位 | 原 CSV | 记录数 |', '|---|---|---:|');
@@ -99,13 +129,13 @@ async function main() {
}
report.push('', '## 重新生成', '', '在仓库根目录运行(原始 ZIP 不提交到 Git):', '', '```powershell', 'npm run merge:banks', '```', '',
'也可指定输入和输出目录:', '', '```powershell', 'node scripts/merge-question-banks.cjs "题库.zip" "题库"', '```', '',
'脚本在写入前调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、岗位、题号及课程来源。无需安装额外依赖。', '',
'脚本在写入前独立逐格核对全部原始字段,并调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、原岗位、题号、来源文件及课程来源。重复导入同一题库不会增加来源记录。无需安装额外依赖。', '',
'原始 ZIP SHA-256:`' + crypto.createHash('sha256').update(input).digest('hex') + '`。', '');
fs.mkdirSync(outputDir, { recursive: true });
outputs.forEach(output => fs.writeFileSync(path.join(outputDir, output.name), output.bytes));
fs.writeFileSync(path.join(outputDir, 'README.md'), report.join('\n'));
console.log('已验证并合并 ' + entries.length + ' 个原始 CSV,共 ' + merged.total + ' 条记录:');
outputs.forEach(output => console.log(output.role + ':' + merged.counts[output.role] + ' 条 → ' + path.join(outputDir, output.name)));
outputs.forEach(output => console.log((output.role || '统一题库') + ':' + (output.role ? merged.counts[output.role] : merged.total) + ' 条 → ' + path.join(outputDir, output.name)));
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });