Files
huayi-question-bank/src/core.js
T

557 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 : {};
const result = { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
if (clean(source.batchId)) result.batchId = idText(source.batchId, '采集批次ID');
return result;
}
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.
const fingerprint = [bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))];
if (record.contentOccurrence != null) fingerprint.push(record.contentOccurrence);
return `fp:${JSON.stringify(fingerprint)}`;
}
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,
};
for (const field of ['questionIndex', 'contentOccurrence']) {
if (input[field] != null) {
if (!Number.isSafeInteger(input[field]) || input[field] < 0) throw new Error(`${field} 必须为非负整数`);
record[field] = input[field];
}
}
if (own(input, 'questionIdAliases')) record.questionIdAliases = unique([...stringArray(input.questionIdAliases, '题目ID别名'), record.questionId].filter(Boolean));
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 metadata = {};
for (const field of ['questionIndex', 'contentOccurrence']) {
if (newRecord[field] != null || oldRecord[field] != null) metadata[field] = newRecord[field] ?? oldRecord[field];
}
if (own(oldRecord, 'questionIdAliases') || own(newRecord, 'questionIdAliases')) metadata.questionIdAliases = unique([
...(oldRecord.questionIdAliases || []), ...(newRecord.questionIdAliases || []), oldRecord.questionId, newRecord.questionId
].filter(Boolean));
const base = { ...newRecord, ...metadata, 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, ...metadata, 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);
}
// Explicit aliases are produced only by content/batch repair. Ordinary
// records with different IDs are still kept separate until that repair.
const bankIdentity = bankResolver([...records.values()]);
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue;
for (const other of [...records.values()]) {
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue;
const current = records.get(target.key);
const aliases = new Set(current.questionIdAliases || []);
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }));
records.delete(other.key);
}
}
for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key);
}
// 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) &&
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
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 contentSignature(record) {
return JSON.stringify([record.type, normalizedStem(record.stem), sorted(optionContents(record))]);
}
function bankResolver(records) {
const namedIds = new Map();
for (const record of records) if (record.bankId && record.bankName !== '未命名题库') {
const name = normalized(record.bankName);
if (!namedIds.has(name)) namedIds.set(name, new Set());
namedIds.get(name).add(record.bankId);
}
return record => {
if (record.bankId) return `id:${record.bankId}`;
const name = normalized(record.bankName);
if (!name || record.bankName === '未命名题库') return `unresolved:${record.key}`;
const ids = namedIds.get(name);
return ids?.size === 1 ? `id:${[...ids][0]}` : `name:${name}`;
};
}
function foldEquivalent(group, identity = {}) {
const existing = group.find(record => record.questionIdAliases?.length > 1);
const first = existing || group.find(record => record.questionId) || group[0];
const questionId = identity.questionId ?? first.questionId;
const bankId = identity.bankId ?? (group.find(record => record.bankId)?.bankId || first.bankId);
const bankName = identity.bankName || first.bankName;
const ids = unique(group.flatMap(record => [record.questionId, ...(record.questionIdAliases || [])]).filter(Boolean));
const meta = { bankId, bankName, questionId };
if (ids.length > 1 || group.some(record => own(record, 'questionIdAliases'))) meta.questionIdAliases = ids;
const occurrence = identity.contentOccurrence ?? first.contentOccurrence ?? group.find(record => record.contentOccurrence != null)?.contentOccurrence;
if (occurrence != null) meta.contentOccurrence = occurrence;
// Process missing observations first so the selected options and the answer
// IDs always come together from an answer-bearing record.
const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing'));
let result;
for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeRecord(result, rebased) : rebased;
}
return result;
}
function compactRecords(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const entries = input.map((value, index) => ({ record: normalizeRecord(value), index, batch: '', occurrence: null }));
const bankIdentity = bankResolver(entries.map(entry => entry.record));
const listSource = record => [record.answerSource, record.source].find(source => source?.kind === 'network' &&
/\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt)));
const legacySources = new Map();
for (const entry of entries) {
const record = entry.record;
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId;
if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`;
else if (entry.occurrence == null) {
const source = listSource(record);
if (!source) continue;
const key = JSON.stringify([bankIdentity(record), source.url]);
if (!legacySources.has(key)) legacySources.set(key, []);
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
}
}
// Older versions timestamped individual rows inside each list response.
// Infer only network list-response clusters; DOM/answer observations are
// never assumed to be full snapshots. Their original order gives duplicate
// occurrence numbers and keeps legitimate repeated questions in each list.
for (const [key, items] of legacySources) {
items.sort((a, b) => a.time - b.time || a.entry.index - b.entry.index);
let previous = -Infinity, batch = '';
for (const item of items) {
if (item.time - previous > 1000) batch = `legacy:${key}:${item.time}`;
item.entry.batch = batch; previous = item.time;
}
}
const counters = new Map();
for (const entry of entries) {
if (!entry.batch || entry.occurrence != null) continue;
const key = JSON.stringify([entry.batch, contentSignature(entry.record)]);
entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1);
}
const groups = new Map();
const byContent = new Map();
const add = (key, entry) => {
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(entry);
};
for (const entry of entries) {
const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]);
entry.content = content;
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
const key = JSON.stringify([content, entry.occurrence]);
add(key, entry);
if (!byContent.has(content)) byContent.set(content, new Set());
byContent.get(content).add(key);
}
for (const entry of entries) {
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue;
const matches = [...(byContent.get(entry.content) || [])];
let key;
const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId)));
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0];
else if (matches.length === 1) key = matches[0];
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`;
add(key, entry);
}
const records = [];
let duplicateGroups = 0;
for (const group of groups.values()) {
if (group.length > 1) duplicateGroups++;
const identity = {};
const occurrence = group.find(entry => entry.occurrence != null)?.occurrence;
if (occurrence != null) identity.contentOccurrence = occurrence;
records.push(foldEquivalent(group.map(entry => entry.record), identity));
}
return { records, removed: input.length - records.length, groups: duplicateGroups, conflictCount: records.filter(record => record.answerStatus === 'conflict').length };
}
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, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;