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
+123
View File
@@ -0,0 +1,123 @@
/* Unified-bank identity, provenance, topic boundaries and durable v2 migration. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const rootDir = path.resolve(__dirname, '..');
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
const V2 = 'yianxue.assistant.bank.v2', V3 = 'yianxue.assistant.bank.v3';
function runtime(memory = new Map(), failWrites = false) {
const environment = {
TextDecoder, URL, URLSearchParams, JSZip,
GM_getValue: (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback,
GM_setValue: (key, value) => { if (failWrites) throw new Error('quota exhausted'); memory.set(key, structuredClone(value)); }
};
vm.createContext(environment);
for (const name of ['bank-store.js', 'answer-matcher.js']) vm.runInContext(fs.readFileSync(path.join(rootDir, name), 'utf8'), environment);
return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, memory };
}
function file(name, content) {
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
}
function row(overrides = {}) {
return { role: '主操', course: '安全领导力-1', sourceUrl: 'http://old/clientcn/exam/leadership/questions', sourceFile: '题库/主操/领导力.csv',
number: 1, type: '单选', question: '技术措施应当怎样执行?',
options: [{ label: 'A', text: '先核对要求' }, { label: 'B', text: '任意操作' }], answer: 'A', ...overrides };
}
(async () => {
const { store, matcher } = runtime();
const origins = [row(), row({ role: '副操', sourceFile: '题库/副操/领导力.csv' }), row({ role: '班长', sourceFile: '题库/班长/领导力.csv' }),
row({ role: '', sourceFile: '独立题库.json' })];
let state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(state.schemaVersion, 3);
assert.equal(state.total, 4, '保留四份来源记录');
assert.equal(state.uniqueTotal, 1, '跨岗位同题同答案仅聚合为一项');
assert.equal(state.questionTotal, 1);
assert.equal(state.conflictTotal, 0);
assert.equal(state.counts.未标明, 1);
for (const role of ['', '主操', '班长', '副操', '旧版遗留值']) {
const found = matcher.search('技术措施', { role }, state);
assert.equal(found.total, 1);
assert.equal(found.items[0].sources.length, 4, '聚合显示全部原岗位和来源文件');
assert.equal(matcher.match(row(), { role }, state).status, 'matched');
}
const reordered = { ...row(), options: [{ label: 'A', text: '任意操作' }, { label: 'B', text: '先核对要求' }] };
const mapped = matcher.match(reordered, {}, state);
assert.deepEqual(Array.from(mapped.correctLabels), ['B']);
assert.deepEqual(Array.from(mapped.correctTexts), ['先核对要求']);
state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(state.total, 4, '重复导入不增加记录');
const conflict = row({ role: '副操', course: '另一主题', sourceUrl: 'http://old/clientcn/exam/other/questions', sourceFile: '另一主题.csv', answer: 'B' });
state = await store.importFiles([file('另一主题.json', JSON.stringify({ records: [conflict] }))]);
assert.equal(state.total, 5);
assert.equal(state.uniqueTotal, 2);
assert.equal(state.questionTotal, 1);
assert.equal(state.conflictTotal, 1);
assert.equal(matcher.match(reordered, {}, state).status, 'conflict', '四比一也不得多数决');
assert(matcher.search('技术措施', {}, state).items.every(item => item.conflict));
assert.equal(matcher.match(reordered, { course: '安全领导力-1' }, state).status, 'matched');
assert.equal(matcher.match(reordered, { sourceUrl: 'http://new/clientcn/exam/other/questions' }, state).correctLabels[0], 'A');
assert.equal(matcher.match(reordered, { courses: ['安全领导力-1'], course: '1-1安全生产方针和目标' }, state).correctLabels[0], 'B');
assert.equal(matcher.match(reordered, { courses: ['另一主题'] }, state).correctLabels[0], 'A');
assert.equal(matcher.match(reordered, { courses: ['不存在'], sourceUrl: origins[0].sourceUrl }, state).status, 'missing', '主题名单没有匹配记录不得退回其他来源');
assert.equal(matcher.match(reordered, { courses: [] }, state).status, 'missing');
assert.equal(matcher.match(reordered, { courses: ['安全领导力'] }, state).status, 'missing', '课程名单必须为全名,关键词不能授权作答');
assert.equal(matcher.match(reordered, { courses: ['安全领导力-1', '另一主题'] }, state).status, 'conflict');
const legacyValue = { schemaVersion: 2, records: origins.concat(origins[0]), revision: 8, updatedAt: '2026-10-17T00:00:00.000Z' };
const oldMemory = new Map([[V2, JSON.stringify(legacyValue)]]);
const oldBackup = oldMemory.get(V2);
const migrated = await runtime(oldMemory).store.load();
assert.equal(migrated.schemaVersion, 3);
assert.equal(migrated.total, 4, '升级过程去掉完全重复的来源记录');
assert.equal(migrated.uniqueTotal, 1);
assert.equal(migrated.revision, 8);
assert.equal(migrated.updatedAt, legacyValue.updatedAt);
assert.equal(migrated.migration.status, 'complete');
assert.equal(oldMemory.get(V2), oldBackup, '升级成功依然保留旧库原值');
assert.equal((await runtime(oldMemory).store.load()).total, 4, '重复加载不会再次追加 v2');
const migratedTwice = await runtime(oldMemory).store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(migratedTwice.total, 4);
const quotaMemory = new Map([[V2, structuredClone(legacyValue)]]);
const quotaBefore = JSON.stringify(quotaMemory.get(V2));
const quota = await runtime(quotaMemory, true).store.load();
assert.equal(quota.total, 4, '迁移保存失败时仍可读取已经校验的旧库');
assert.equal(quota.migration.status, 'pending');
assert.match(quota.migration.error, /quota/);
assert.equal(JSON.stringify(quotaMemory.get(V2)), quotaBefore);
assert.equal(quotaMemory.has(V3), false, '失败不得写入半份新库');
for (const damaged of ['not json', { records: [row({ answer: 'H' })] }, { records: 'bad' }]) {
const memory = new Map([[V2, damaged]]);
const before = JSON.stringify(memory.get(V2));
await assert.rejects(runtime(memory).store.load(), /损坏|无效/);
assert.equal(JSON.stringify(memory.get(V2)), before);
assert.equal(memory.has(V3), false);
}
const corruptedV3 = new Map([[V2, legacyValue], [V3, { schemaVersion: 3, records: [row({ correctTexts: ['任意操作'] })] }]]);
await assert.rejects(runtime(corruptedV3).store.load(), /无效/, 'v3 损坏时不得静默恢复较旧题库并覆盖新数据');
assert.deepEqual(corruptedV3.get(V2), legacyValue);
const unifiedPath = path.join(rootDir, '题库', '统一题库.csv');
if (fs.existsSync(unifiedPath)) {
const real = runtime();
const unifiedFile = file('统一题库.csv', fs.readFileSync(unifiedPath));
const once = await real.store.importFiles([unifiedFile]);
const twice = await real.store.importFiles([unifiedFile]);
assert.equal(once.total, 5520);
assert.equal(twice.total, once.total);
assert.equal(twice.uniqueTotal, once.uniqueTotal);
assert.equal(new Set(twice.records.map(record => record.sourceFile)).size, 28);
assert(twice.conflictTotal > 0, '真实跨来源冲突必须保留');
console.log('PASS 统一 CSV:5520 条来源 / ' + twice.uniqueTotal + ' 个答案组合 / ' + twice.questionTotal + ' 道题 / ' + twice.conflictTotal + ' 道冲突题,重复导入记录数不变');
}
console.log('PASS 统一题库跨岗位与无岗位、文字映射、来源聚合、冲突不多数决、准确主题名单、v2 安全迁移及损坏/配额保护');
})().catch(error => { console.error(error); process.exitCode = 1; });