feat: add Huayi question bank assistant v1.1.0
This commit is contained in:
+399
@@ -0,0 +1,399 @@
|
||||
/* Pure question-bank operations. No page access or network requests. */
|
||||
const QBCore = (() => {
|
||||
'use strict';
|
||||
|
||||
const TYPES = new Set(['single', 'multiple', 'judge', 'unknown']);
|
||||
const STATUSES = new Set(['confirmed', 'missing', 'conflict']);
|
||||
const TYPE_NAMES = { single: '单选题', multiple: '多选题', judge: '判断题', unknown: '未知' };
|
||||
const STATUS_NAMES = { confirmed: '已确认', missing: '待补', conflict: '冲突' };
|
||||
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
const text = (value) => value == null ? '' : String(value);
|
||||
const unique = (values) => [...new Set(values)];
|
||||
const clean = (value) => text(value).replace(/\u00a0/g, ' ').trim();
|
||||
// Do not use NFKC: it changes superscripts, units and some mathematical symbols.
|
||||
const normalized = (value) => clean(value).replace(/\s+/gu, '');
|
||||
const normalizedStem = (value) => normalized(clean(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''));
|
||||
const sorted = (values) => [...values].sort();
|
||||
const sameArray = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
|
||||
|
||||
function idText(value, name) {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error(`${name} 是不安全的数字,请以字符串提供`);
|
||||
if (value != null && !['string', 'number'].includes(typeof value)) throw new Error(`${name} 必须是字符串`);
|
||||
return clean(value);
|
||||
}
|
||||
|
||||
function stringArray(value, name) {
|
||||
if (value == null) return [];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' && typeof item !== 'number')) {
|
||||
throw new Error(`${name} 必须是字符串数组`);
|
||||
}
|
||||
return unique(value.map((item) => idText(item, name)).filter(Boolean));
|
||||
}
|
||||
|
||||
function judgeText(value) {
|
||||
const result = clean(value);
|
||||
if (/^(正确|对|是|√|true)$/i.test(result)) return '正确';
|
||||
if (/^(错误|错|否|×|false)$/i.test(result)) return '错误';
|
||||
return result;
|
||||
}
|
||||
|
||||
const answerText = (value, type) => type === 'judge' ? judgeText(value) : clean(value);
|
||||
const comparableAnswer = (value, type) => normalized(answerText(value, type));
|
||||
const optionContents = (record) => record.options.map((option) => comparableAnswer(option.text, record.type));
|
||||
const sameContent = (a, b) => normalizedStem(a.stem) === normalizedStem(b.stem) &&
|
||||
(a.type === b.type || a.type === 'unknown' || b.type === 'unknown') && sameArray(optionContents(a), optionContents(b));
|
||||
|
||||
function sourceInfo(value) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
return { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
|
||||
}
|
||||
|
||||
function recordKey(record) {
|
||||
const bank = record.bankId || record.bankName;
|
||||
if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`;
|
||||
// Keep the complete signature, rather than a short hash whose collisions could merge answers.
|
||||
return `fp:${JSON.stringify([bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))])}`;
|
||||
}
|
||||
|
||||
function normalizeRecord(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('题目记录必须是对象');
|
||||
if (typeof input.stem !== 'string' || !input.stem.trim()) throw new Error('题干不能为空');
|
||||
const type = input.type == null ? 'unknown' : input.type;
|
||||
if (!TYPES.has(type)) throw new Error(`不支持的题型:${text(type)}`);
|
||||
if (input.answerStatus != null && !STATUSES.has(input.answerStatus)) throw new Error(`无效答案状态:${text(input.answerStatus)}`);
|
||||
if (input.options != null && !Array.isArray(input.options)) throw new Error('选项必须是数组');
|
||||
const options = (input.options || []).map((option, index) => {
|
||||
if (typeof option === 'string') option = { text: option };
|
||||
if (!option || typeof option !== 'object' || typeof option.text !== 'string' || !option.text.trim()) throw new Error('选项内容不能为空');
|
||||
return { id: idText(option.id, '选项ID'), text: clean(option.text), label: clean(option.label) || (index < 26 ? String.fromCharCode(65 + index) : String(index + 1)) };
|
||||
});
|
||||
const correctOptionIds = stringArray(input.correctOptionIds, '正确选项ID');
|
||||
let answerTexts = stringArray(input.answerTexts, '正确答案内容').map((value) => answerText(value, type));
|
||||
answerTexts = unique(answerTexts);
|
||||
let answerStatus = input.answerStatus || (correctOptionIds.length || answerTexts.length ? 'confirmed' : 'missing');
|
||||
const conflicts = Array.isArray(input.conflicts) ? input.conflicts.filter((item) => item && typeof item === 'object').map((item) => JSON.parse(JSON.stringify(item))) : [];
|
||||
let inconsistency = '';
|
||||
const derivedTexts = [];
|
||||
if (correctOptionIds.length && options.length) {
|
||||
for (const id of correctOptionIds) {
|
||||
const matches = options.filter((option) => option.id === id);
|
||||
if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项';
|
||||
else derivedTexts.push(answerText(matches[0].text, type));
|
||||
}
|
||||
if (!inconsistency && answerTexts.length && !sameArray(answerTexts.map((value) => comparableAnswer(value, type)), derivedTexts.map((value) => comparableAnswer(value, type)))) {
|
||||
inconsistency = '正确选项ID与正确答案内容不一致';
|
||||
}
|
||||
if (!answerTexts.length && !inconsistency) answerTexts = unique(derivedTexts);
|
||||
}
|
||||
if (['single', 'judge'].includes(type) && (answerTexts.length > 1 || correctOptionIds.length > 1)) inconsistency = '单选或判断题存在多个正确答案';
|
||||
if (options.length && ['single', 'multiple', 'judge'].includes(type) && answerTexts.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) {
|
||||
inconsistency = '正确答案内容不属于已保存选项';
|
||||
}
|
||||
if (answerStatus === 'confirmed' && !answerTexts.length && !correctOptionIds.length) answerStatus = 'missing';
|
||||
if (answerStatus !== 'missing' && inconsistency) {
|
||||
answerStatus = 'conflict';
|
||||
if (!conflicts.some((item) => item.reason === inconsistency)) conflicts.push({ reason: inconsistency, answerTexts: [...answerTexts], correctOptionIds: [...correctOptionIds] });
|
||||
}
|
||||
// Explicitly missing data must never carry an apparently confirmed answer forward.
|
||||
if (answerStatus === 'missing') answerTexts = [];
|
||||
const source = sourceInfo(input.source);
|
||||
const record = {
|
||||
bankId: idText(input.bankId, '题库ID'), bankName: clean(input.bankName) || '未命名题库',
|
||||
questionId: idText(input.questionId, '题目ID'), type, stem: clean(input.stem), options,
|
||||
correctOptionIds: answerStatus === 'missing' ? [] : correctOptionIds, answerTexts, answerStatus,
|
||||
explanation: clean(input.explanation), source, updatedAt: clean(input.updatedAt) || source.observedAt, conflicts,
|
||||
};
|
||||
if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source);
|
||||
record.key = recordKey(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function answerSignature(record) {
|
||||
if (record.answerTexts.length) return JSON.stringify(sorted(record.answerTexts.map((value) => comparableAnswer(value, record.type))));
|
||||
return JSON.stringify(sorted(record.correctOptionIds.map((value) => `id:${value}`)));
|
||||
}
|
||||
|
||||
function conflictSnapshot(record, reason) {
|
||||
return { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source };
|
||||
}
|
||||
|
||||
function distinctConflicts(conflicts) {
|
||||
const byContent = new Map();
|
||||
for (const item of conflicts) {
|
||||
const key = JSON.stringify([item.reason, item.stem, item.options, item.answerTexts, item.correctOptionIds]);
|
||||
if (!byContent.has(key)) byContent.set(key, item);
|
||||
}
|
||||
return [...byContent.values()].slice(-40);
|
||||
}
|
||||
|
||||
function mergeRecord(existing, incoming) {
|
||||
const oldRecord = normalizeRecord(existing);
|
||||
const newRecord = normalizeRecord(incoming);
|
||||
if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
|
||||
const contentMatches = sameContent(oldRecord, newRecord);
|
||||
const explanation = newRecord.explanation || oldRecord.explanation;
|
||||
const base = { ...newRecord, explanation };
|
||||
const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts];
|
||||
const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict';
|
||||
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
|
||||
const contentChanged = !contentMatches && oldRecord.answerStatus !== 'missing';
|
||||
if (conflictAlready || answersDiffer || contentChanged) {
|
||||
const reason = contentChanged ? '同一题目ID的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案';
|
||||
if (oldRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(oldRecord, reason));
|
||||
if (newRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(newRecord, reason));
|
||||
const answerRecord = newRecord.answerStatus !== 'missing' ? newRecord : oldRecord;
|
||||
return normalizeRecord({ ...base, answerTexts: answerRecord.answerTexts, correctOptionIds: answerRecord.correctOptionIds,
|
||||
answerSource: answerRecord.answerSource || answerRecord.source, answerStatus: 'conflict',
|
||||
conflicts: distinctConflicts(conflicts) });
|
||||
}
|
||||
if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') {
|
||||
// The incoming DOM may use different IDs/order. Keep the original answer's own options and IDs together.
|
||||
return normalizeRecord({ ...oldRecord, bankName: newRecord.bankName || oldRecord.bankName, explanation,
|
||||
updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source });
|
||||
}
|
||||
return normalizeRecord({ ...base, conflicts });
|
||||
}
|
||||
|
||||
function upsert(recordsArray, incomingRecords) {
|
||||
if (!Array.isArray(recordsArray) || !Array.isArray(incomingRecords)) throw new Error('题库必须是数组');
|
||||
const records = new Map();
|
||||
const lastSeen = new Map();
|
||||
for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) {
|
||||
const record = normalizeRecord(item);
|
||||
records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record);
|
||||
lastSeen.set(record.key, index);
|
||||
}
|
||||
// The saved DOM has no stable question ID. Upgrade it only when the complete
|
||||
// content and bank identify one unique server record in both directions.
|
||||
const anonymous = [...records.values()].filter((record) => !record.questionId);
|
||||
const identified = [...records.values()].filter((record) => record.questionId);
|
||||
const sameBank = (a, b) => {
|
||||
if (a.bankId && b.bankId) return a.bankId === b.bankId;
|
||||
if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false;
|
||||
return normalized(a.bankName) === normalized(b.bankName);
|
||||
};
|
||||
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b);
|
||||
const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))]));
|
||||
for (const record of anonymous) {
|
||||
const matches = candidates.get(record.key);
|
||||
if (matches.length !== 1) continue;
|
||||
const target = matches[0];
|
||||
if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue;
|
||||
const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId });
|
||||
const currentTarget = records.get(target.key);
|
||||
const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted);
|
||||
records.set(target.key, merged);
|
||||
records.delete(record.key);
|
||||
}
|
||||
return [...records.values()];
|
||||
}
|
||||
|
||||
function bigrams(value) {
|
||||
const chars = Array.from(value);
|
||||
if (chars.length < 2) return new Set(chars);
|
||||
return new Set(chars.slice(0, -1).map((char, index) => char + chars[index + 1]));
|
||||
}
|
||||
|
||||
function search(records, query, { limit = 8, field = 'all', offset = 0 } = {}) {
|
||||
const queryText = clean(query).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, '');
|
||||
const needle = normalized(queryText);
|
||||
if (needle.length < 2) return [];
|
||||
const selectedField = ['all', 'stem', 'options', 'answer'].includes(field) ? field : 'all';
|
||||
const terms = unique(queryText.split(/\s+/u).map(normalized).filter(Boolean));
|
||||
const queryGrams = bigrams(needle);
|
||||
const result = [];
|
||||
for (const record of records) {
|
||||
const haystack = normalizedStem(record.stem);
|
||||
// Each option/answer stays a separate searchable segment. Joining option A
|
||||
// and option B would create phrases that never existed in either option.
|
||||
const fields = {
|
||||
stem: [haystack],
|
||||
options: (record.options || []).map((option) => normalized(option.text)),
|
||||
answer: record.answerStatus === 'confirmed' ? (record.answerTexts || []).map(normalized) : [],
|
||||
};
|
||||
const allowed = selectedField === 'all' ? ['stem', 'options', 'answer'] : [selectedField];
|
||||
let match, score = 0, matchedField;
|
||||
const prefer = (candidateField, candidateScore, candidateMatch = 'partial') => {
|
||||
if (candidateScore > score) { matchedField = candidateField; score = candidateScore; match = candidateMatch; }
|
||||
};
|
||||
if (allowed.includes('stem')) {
|
||||
if (haystack === needle) prefer('stem', 1, 'exact');
|
||||
else if (haystack.includes(needle)) prefer('stem', 0.86 + 0.08 * needle.length / haystack.length);
|
||||
}
|
||||
for (const candidateField of ['options', 'answer']) {
|
||||
if (!allowed.includes(candidateField)) continue;
|
||||
const matching = fields[candidateField].filter((segment) => segment.includes(needle));
|
||||
if (matching.length) {
|
||||
const coverage = Math.max(...matching.map((segment) => needle.length / segment.length));
|
||||
prefer(candidateField, (candidateField === 'options' ? 0.72 : 0.60) + 0.06 * coverage);
|
||||
}
|
||||
}
|
||||
if (terms.length > 1) {
|
||||
const allIn = (names) => terms.every((term) => names.some((name) => fields[name].some((segment) => segment.includes(term))));
|
||||
if (allIn(allowed)) {
|
||||
if (allowed.includes('stem') && allIn(['stem'])) prefer('stem', 0.82);
|
||||
else if (allowed.includes('options') && allIn(['options'])) prefer('options', 0.70);
|
||||
else if (allowed.includes('answer') && allIn(['answer'])) prefer('answer', 0.58);
|
||||
else {
|
||||
// Cross-field AND is available only with the explicit all-fields scope.
|
||||
const firstField = allowed.find((name) => terms.some((term) => fields[name].some((segment) => segment.includes(term))));
|
||||
prefer(firstField, firstField === 'stem' ? 0.80 : firstField === 'options' ? 0.68 : 0.56);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!match && allowed.includes('stem') && needle.length >= 4 && terms.length === 1) {
|
||||
const grams = bigrams(haystack);
|
||||
const intersection = [...queryGrams].filter((gram) => grams.has(gram)).length;
|
||||
const similarity = 2 * intersection / (queryGrams.size + grams.size || 1);
|
||||
if (similarity >= 0.45) prefer('stem', similarity * 0.50, 'similar');
|
||||
}
|
||||
if (match) result.push({ record, match, score, matchedField });
|
||||
}
|
||||
const stableKey = (record) => JSON.stringify([record.bankId || '', record.bankName || '', record.questionId || '', record.key || '']);
|
||||
result.sort((a, b) => b.score - a.score || a.record.stem.localeCompare(b.record.stem, 'zh-CN') || stableKey(a.record).localeCompare(stableKey(b.record), 'zh-CN'));
|
||||
const start = Math.max(0, Math.floor(Number(offset) || 0));
|
||||
const count = Math.max(0, Math.min(100, Math.floor(Number(limit) || 0)));
|
||||
return result.slice(start, start + count);
|
||||
}
|
||||
|
||||
function mapAnswers(recordInput, currentInput) {
|
||||
let record, current;
|
||||
try { record = normalizeRecord(recordInput); current = normalizeRecord(currentInput); }
|
||||
catch (_) { return { status: 'unmapped', labels: [], answerTexts: [] }; }
|
||||
const answerTexts = [...record.answerTexts];
|
||||
const result = (status, labels = []) => ({ status, labels, answerTexts });
|
||||
if (record.answerStatus === 'conflict') return result('ambiguous');
|
||||
if (record.answerStatus !== 'confirmed' || !answerTexts.length) return result('unmapped');
|
||||
if (normalizedStem(record.stem) !== normalizedStem(current.stem) ||
|
||||
(record.type !== 'unknown' && current.type !== 'unknown' && record.type !== current.type)) return result('unmapped');
|
||||
if (record.options.length && !sameArray(optionContents(record), current.options.map((option) => comparableAnswer(option.text, record.type)))) return result('unmapped');
|
||||
const labels = [];
|
||||
for (const value of answerTexts) {
|
||||
const expected = comparableAnswer(value, record.type);
|
||||
const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected);
|
||||
if (candidates.length > 1) return result('ambiguous');
|
||||
if (!candidates.length) return result('unmapped');
|
||||
const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected);
|
||||
for (const original of originalCorrect) {
|
||||
const idCandidates = original.id ? current.options.filter((option) => option.id === original.id) : [];
|
||||
if (idCandidates.length > 1) return result('ambiguous');
|
||||
if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped');
|
||||
}
|
||||
labels.push(candidates[0].label);
|
||||
}
|
||||
if (unique(labels).length !== labels.length) return result('ambiguous');
|
||||
return result('matched', labels);
|
||||
}
|
||||
|
||||
function csvSafe(value) {
|
||||
const result = text(value);
|
||||
// CSV quoting alone does not stop spreadsheet formula evaluation or long-ID rounding.
|
||||
return /^(?:\s*[=+\-@]|[\t\r]|\d{16,}$)/u.test(result) ? `'${result}` : result;
|
||||
}
|
||||
|
||||
function csvRestore(value) {
|
||||
return value.startsWith("'") && csvSafe(value.slice(1)) === value ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
function csvCell(value) { return `"${csvSafe(value).replace(/"/g, '""')}"`; }
|
||||
|
||||
function toCSV(records) {
|
||||
const canonical = records.map(normalizeRecord);
|
||||
const optionCount = Math.max(0, ...canonical.map((record) => record.options.length));
|
||||
const optionNames = Array.from({ length: optionCount }, (_, index) => `选项${index < 26 ? String.fromCharCode(65 + index) : index + 1}`);
|
||||
const headers = ['来源题库', '题库ID', '题目ID', '题型', '题干', ...optionNames, '正确答案内容', '采集时答案字母', '解析', '答案状态', '答案来源', '采集时间', '完整记录JSON'];
|
||||
const rows = [headers];
|
||||
for (const record of canonical) {
|
||||
const mapped = mapAnswers(record, record);
|
||||
rows.push([record.bankName, record.bankId, record.questionId, TYPE_NAMES[record.type], record.stem,
|
||||
...Array.from({ length: optionCount }, (_, index) => record.options[index]?.text || ''),
|
||||
record.answerTexts.join('\n'), mapped.status === 'matched' ? mapped.labels.join(',') : '', record.explanation,
|
||||
STATUS_NAMES[record.answerStatus], record.answerSource?.kind || record.source.kind, record.answerSource?.observedAt || record.source.observedAt, JSON.stringify(record)]);
|
||||
}
|
||||
return '\uFEFF' + rows.map((row) => row.map(csvCell).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function parseCSV(input) {
|
||||
if (typeof input !== 'string') throw new Error('CSV内容必须是文字');
|
||||
const value = input.replace(/^\uFEFF/, '');
|
||||
const rows = [];
|
||||
let row = [], cell = '', quoted = false, closed = false;
|
||||
const finishCell = () => { row.push(cell); cell = ''; closed = false; };
|
||||
const finishRow = () => { finishCell(); if (row.some((item) => item !== '')) rows.push(row); row = []; };
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
if (quoted) {
|
||||
if (char === '"') {
|
||||
if (value[index + 1] === '"') { cell += '"'; index += 1; }
|
||||
else { quoted = false; closed = true; }
|
||||
} else cell += char;
|
||||
} else if (char === ',') finishCell();
|
||||
else if (char === '\n' || char === '\r') { finishRow(); if (char === '\r' && value[index + 1] === '\n') index += 1; }
|
||||
else if (char === '"') {
|
||||
if (cell || closed) throw new Error('CSV引号位置错误');
|
||||
quoted = true;
|
||||
} else {
|
||||
if (closed) throw new Error('CSV引号后包含非法字符');
|
||||
cell += char;
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error('CSV存在未闭合引号');
|
||||
if (cell || row.length || closed) finishRow();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function importedArray(value, type, options) {
|
||||
const trimmed = clean(value);
|
||||
if (!trimmed) return [];
|
||||
if (trimmed.startsWith('[')) {
|
||||
let result;
|
||||
try { result = JSON.parse(trimmed); } catch (_) { throw new Error('正确答案内容JSON格式错误'); }
|
||||
return stringArray(result, '正确答案内容');
|
||||
}
|
||||
if (type !== 'multiple' || options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(trimmed, type))) return [trimmed];
|
||||
const answers = trimmed.split(/\r?\n/).map(clean).filter(Boolean);
|
||||
if (answers.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) throw new Error('多选答案请使用JSON数组,或每行填写一个完整选项内容');
|
||||
return answers;
|
||||
}
|
||||
|
||||
function fromCSV(value) {
|
||||
const rows = parseCSV(value);
|
||||
if (!rows.length) throw new Error('CSV为空');
|
||||
const headers = rows.shift().map((item) => clean(csvRestore(item)));
|
||||
if (unique(headers).length !== headers.length) throw new Error('CSV存在重复列名');
|
||||
if (!headers.includes('完整记录JSON') && (!headers.includes('题干') || !headers.includes('题型'))) throw new Error('CSV需要“题干、题型”列或“完整记录JSON”列');
|
||||
const typeAliases = { ...Object.fromEntries(Object.entries(TYPE_NAMES).map(([key, name]) => [name, key])), 单选: 'single', 多选: 'multiple', 判断: 'judge', single: 'single', multiple: 'multiple', judge: 'judge', unknown: 'unknown' };
|
||||
const statusAliases = { ...Object.fromEntries(Object.entries(STATUS_NAMES).map(([key, name]) => [name, key])), confirmed: 'confirmed', missing: 'missing', conflict: 'conflict', 无答案: 'missing', 待核验: 'missing' };
|
||||
const records = [];
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const cells = rows[index];
|
||||
try {
|
||||
if (cells.length !== headers.length) throw new Error(`列数应为${headers.length},实际为${cells.length}`);
|
||||
const row = Object.fromEntries(headers.map((header, column) => [header, csvRestore(cells[column])]));
|
||||
if (clean(row['完整记录JSON'])) {
|
||||
let raw;
|
||||
try { raw = JSON.parse(row['完整记录JSON']); } catch (_) { throw new Error('完整记录JSON格式错误'); }
|
||||
records.push(normalizeRecord(raw));
|
||||
continue;
|
||||
}
|
||||
const type = typeAliases[clean(row['题型'])];
|
||||
if (!type) throw new Error('题型必须为单选题、多选题、判断题或未知');
|
||||
const options = headers.filter((header) => /^选项(?:[A-Z]+|\d+)$/u.test(header) && clean(row[header])).map((header) => ({ id: '', label: header.slice(2), text: row[header] }));
|
||||
const answers = importedArray(row['正确答案内容'], type, options);
|
||||
const statusText = clean(row['答案状态']);
|
||||
if (statusText && !own(statusAliases, statusText)) throw new Error('无效答案状态');
|
||||
const answerStatus = statusText ? statusAliases[statusText] : answers.length ? 'confirmed' : 'missing';
|
||||
const record = normalizeRecord({ bankId: row['题库ID'], bankName: row['来源题库'], questionId: row['题目ID'], type,
|
||||
stem: row['题干'], options, answerTexts: answers, answerStatus, explanation: row['解析'],
|
||||
source: { kind: clean(row['答案来源']) || 'csv-import', observedAt: row['采集时间'] } });
|
||||
if (record.answerStatus === 'confirmed' && record.options.length && mapAnswers(record, record).status !== 'matched') throw new Error('正确答案内容不能唯一对应选项');
|
||||
records.push(record);
|
||||
} catch (error) { throw new Error(`CSV第${index + 2}行:${error.message}`); }
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
return { normalizeRecord, mergeRecord, upsert, search, mapAnswers, toCSV, fromCSV };
|
||||
})();
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
|
||||
Reference in New Issue
Block a user