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

852 lines
55 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(unique(answerTexts.map((value) => comparableAnswer(value, type))), unique(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) {
// IDs retain how many identical-text options are correct; answerTexts is a set.
if (record.correctOptionIds.length && record.options.length) {
const correct = record.options.filter((option) => record.correctOptionIds.includes(option.id));
return JSON.stringify(sorted(correct.map((option) => comparableAnswer(option.text, record.type))));
}
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}`)));
}
// Old versions compared the list of ID-derived answers with a text set. When
// two correct options shared text, that comparison produced a false conflict.
// Repair only that evidenced shape; normalizeRecord keeps all other explicit
// conflicts conservative. The original inputs are returned for durable backup.
function repairDuplicateOptionConflicts(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const repaired = [];
const legacyReason = '正确选项ID与正确答案内容不一致';
const mergeReason = '同一题目出现不同或不一致的标准答案';
const ids = values => Array.isArray(values) && values.length > 0 && values.every(value => typeof value === 'string' && clean(value)) &&
new Set(values.map(clean)).size === values.length ? values.map(clean) : null;
const answers = values => Array.isArray(values) && values.length > 0 && values.every(value => typeof value === 'string' && normalized(value))
? unique(values.map(normalized)) : null;
const optionSignature = options => {
if (!Array.isArray(options) || !options.length || options.some(option => !option || typeof option !== 'object' ||
typeof option.id !== 'string' || !clean(option.id) || typeof option.text !== 'string' || !normalized(option.text))) return null;
if (new Set(options.map(option => clean(option.id))).size !== options.length) return null;
return options.map(option => JSON.stringify([clean(option.id), normalized(option.text)]));
};
const records = input.map((original, index) => {
const record = normalizeRecord(original);
if (record.type !== 'multiple' || record.answerStatus !== 'conflict' || !Array.isArray(original.conflicts) || !original.conflicts.length) return record;
const optionIds = optionSignature(original.options);
const correctIds = ids(original.correctOptionIds);
const answerSet = answers(original.answerTexts);
if (!optionIds || !correctIds || !answerSet) return record;
const derived = correctIds.map(id => record.options.find(option => option.id === id));
if (derived.some(option => !option)) return record;
const derivedAnswers = derived.map(option => normalized(option.text));
if (new Set(derivedAnswers).size === derivedAnswers.length || !sameArray(unique(derivedAnswers), answerSet)) return record;
const source = original.answerSource || original.source;
const sameSource = candidate => candidate && typeof candidate === 'object' && !Array.isArray(candidate) && source &&
typeof source === 'object' && !Array.isArray(source) && (clean(source.batchId) || (clean(source.url) && clean(source.observedAt))) &&
['kind', 'url', 'observedAt', 'batchId'].every(field => clean(candidate[field]) === clean(source[field]));
const valid = original.conflicts.every(snapshot => {
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) || ![legacyReason, mergeReason].includes(snapshot.reason)) return false;
const snapshotIds = ids(snapshot.correctOptionIds), snapshotAnswers = answers(snapshot.answerTexts);
if (!snapshotIds || !snapshotAnswers || !sameArray(snapshotIds, correctIds) || !sameArray(snapshotAnswers, answerSet)) return false;
if ((own(snapshot, 'stem') || own(snapshot, 'options')) && (!own(snapshot, 'stem') || !own(snapshot, 'options'))) return false;
if (own(snapshot, 'stem') && (typeof snapshot.stem !== 'string' || normalized(snapshot.stem) !== normalized(record.stem))) return false;
if (own(snapshot, 'options')) {
const signature = optionSignature(snapshot.options);
if (!signature || !sameArray(signature, optionIds)) return false;
}
for (const field of ['type', 'bankId', 'bankName', 'questionId']) if (own(snapshot, field) && snapshot[field] !== record[field]) return false;
if (own(snapshot, 'answerStatus') && !['confirmed', 'conflict'].includes(snapshot.answerStatus)) return false;
for (const field of ['source', 'answerSource']) if (own(snapshot, field) && !sameSource(snapshot[field])) return false;
for (const field of ['batchId', 'batch']) if (own(snapshot, field) &&
(typeof snapshot[field] !== 'string' || !clean(snapshot[field]) || clean(snapshot[field]) !== clean(source?.batchId))) return false;
return true;
});
if (!valid || !original.conflicts.some(snapshot => snapshot.reason === legacyReason)) return record;
const restored = normalizeRecord({ ...record, answerStatus: 'confirmed', conflicts: [] });
if (restored.answerStatus !== 'confirmed') return record;
repaired.push({ index, key: record.key, bankId: record.bankId, questionId: record.questionId, reason: 'duplicate-correct-option-text', original });
return restored;
});
return { records, repaired };
}
// Upgrade the old text-only representation only against an evidenced complete
// current network snapshot. A matching text fragment alone cannot prove that
// two image questions are equivalent.
function upgradeLegacyImageRecords(existingInput, incomingInput, { bankId = '', expectedCount, fullSnapshot = false } = {}) {
if (!Array.isArray(existingInput) || !Array.isArray(incomingInput)) throw new Error('题库必须是数组');
const existing = existingInput.map(normalizeRecord), incoming = incomingInput.map(normalizeRecord);
const unchanged = () => ({ existing, incoming, upgraded: [] });
if (!fullSnapshot || !bankId || !Number.isSafeInteger(expectedCount) || expectedCount < 1 || incoming.length !== expectedCount) return unchanged();
const isList = source => source?.kind === 'network' && /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt));
const batch = incoming[0]?.source.batchId;
if (!batch || incoming.some(record => record.bankId !== bankId || !isList(record.source) || record.source.batchId !== batch ||
!Number.isSafeInteger(record.questionIndex) || record.questionIndex < 0 || record.questionIndex >= expectedCount) ||
new Set(incoming.map(record => record.questionIndex)).size !== expectedCount || new Set(incoming.map(record => record.questionId)).size !== expectedCount || incoming.some(record => !record.questionId)) return unchanged();
const marker = /\[图片:https?:\/\/[^\]\s]+\]/gu;
const strip = value => normalized(value.replace(marker, ''));
const imageCount = record => [record.stem, ...record.options.map(option => option.text)].reduce((count, value) => count + (value.match(marker)?.length || 0), 0);
const signature = record => JSON.stringify([record.type, strip(record.stem), sorted(record.options.map(option => strip(option.text)))]);
const existingBank = existing.filter(record => record.bankId === bankId);
const countSignatures = records => {
const map = new Map();
for (const record of records) { const key = signature(record); map.set(key, (map.get(key) || 0) + 1); }
return map;
};
const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming);
const previousBySignature = new Map(), indexCounts = new Map(), originalByKey = new Map();
existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); });
for (const record of existingBank) {
const key = signature(record);
if (!previousBySignature.has(key)) previousBySignature.set(key, record);
indexCounts.set(record.questionIndex, (indexCounts.get(record.questionIndex) || 0) + 1);
}
const correctContents = record => {
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length ||
record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length) return null;
const options = record.correctOptionIds.map(id => record.options.find(option => option.id === id));
return options.some(option => !option) ? null : options.map(option => strip(option.text));
};
const upgraded = [], removed = new Set();
for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current);
if (!imageCount(current) || current.type === 'unknown' || current.options.length < 2 || !strip(current.stem) ||
oldCounts.get(key) !== 1 || newCounts.get(key) !== 1 || new Set(current.options.map(option => strip(option.text))).size !== current.options.length ||
current.options.some(option => !strip(option.text))) continue;
const previous = previousBySignature.get(key);
if (!previous.questionId || imageCount(previous) || previous.type !== current.type ||
!Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) ||
(previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) ||
indexCounts.get(current.questionIndex) !== 1) continue;
const oldAnswers = correctContents(previous), newAnswers = correctContents(current);
if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) ||
!sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement;
removed.add(previous.key);
upgraded.push({ original: originalByKey.get(previous.key), replacement,
bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
}
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
}
// These are deliberately evidenced legacy spellings, not an HTML decoder.
// Literal entities elsewhere stay distinct in normalization/search/compaction.
function upgradeLegacyMathEntities(existingInput, incomingInput, { bankId = '', expectedCount, fullSnapshot = false } = {}) {
if (!Array.isArray(existingInput) || !Array.isArray(incomingInput)) throw new Error('题库必须是数组');
const existing = existingInput.map(normalizeRecord), incoming = incomingInput.map(normalizeRecord);
const unchanged = () => ({ existing, incoming, upgraded: [] });
if (!fullSnapshot || !bankId || !Number.isSafeInteger(expectedCount) || expectedCount < 1 || incoming.length !== expectedCount) return unchanged();
const isList = source => source?.kind === 'network' && /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt));
const batch = incoming[0]?.source.batchId;
if (!batch || incoming.some(record => record.bankId !== bankId || !isList(record.source) || record.source.batchId !== batch ||
!Number.isSafeInteger(record.questionIndex) || record.questionIndex < 0 || record.questionIndex >= expectedCount || !record.questionId) ||
new Set(incoming.map(record => record.questionIndex)).size !== expectedCount || new Set(incoming.map(record => record.questionId)).size !== expectedCount) return unchanged();
const content = record => [record.stem, ...record.options.map(option => option.text)];
const entities = { '&times;': '×', '&plusmn;': '±' };
const hasEntity = record => content(record).some(value => /&(?:times|plusmn);/.test(value));
const canonical = value => normalized(value.replace(/&(?:times|plusmn);/g, entity => entities[entity]));
const signature = record => JSON.stringify([record.type, canonical(record.stem), sorted(record.options.map(option => canonical(option.text)))]);
const optionPairs = record => record.options.map(option => JSON.stringify([option.id, canonical(option.text)]));
const correctContents = record => {
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || record.options.length < 2 ||
record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length ||
new Set(record.options.map(option => canonical(option.text))).size !== record.options.length) return null;
const selected = record.correctOptionIds.map(id => record.options.find(option => option.id === id));
return selected.some(option => !option) ? null : selected.map(option => canonical(option.text));
};
const sameAnswers = (a, b) => {
const left = correctContents(a), right = correctContents(b);
return left && right && sameArray(left, right) && sameArray(a.correctOptionIds, b.correctOptionIds) &&
sameArray(unique(a.answerTexts.map(canonical)), unique(b.answerTexts.map(canonical)));
};
const sameModern = (record, current) => !hasEntity(record) && record.type === current.type && record.questionIndex === current.questionIndex &&
normalized(record.stem) === normalized(current.stem) && sameArray(optionPairs(record), optionPairs(current)) && isList(record.source) && sameAnswers(record, current);
const bank = existing.filter(record => record.bankId === bankId), upgraded = [], removed = new Set();
const incomingCounts = new Map(), bankBySignature = new Map(), legacyBySignature = new Map(), bankByIndex = new Map(), originalByKey = new Map();
existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); });
const add = (map, key, record) => {
if (!map.has(key)) map.set(key, []);
map.get(key).push(record);
};
for (const record of incoming) {
const key = signature(record);
incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1);
}
for (const record of bank) {
const key = signature(record);
add(bankBySignature, key, record);
add(bankByIndex, record.questionIndex, record);
if (hasEntity(record)) add(legacyBySignature, key, record);
}
for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current);
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue;
const candidates = legacyBySignature.get(key) || [];
if (candidates.length !== 1) continue;
const previous = candidates[0];
if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) ||
!isList(previous.source) || (previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) ||
!sameArray(optionPairs(previous), optionPairs(current)) || !sameAnswers(previous, current)) continue;
// A previous failed export may already contain both spellings. Permit only
// fully agreeing modern copies at that very same source position; distinct
// literal-entity questions and duplicate occurrences remain untouched.
if ([...(bankBySignature.get(key) || []), ...(bankByIndex.get(current.questionIndex) || [])].some(record =>
record.key !== previous.key && !sameModern(record, current))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement;
removed.add(previous.key);
upgraded.push({ original: originalByKey.get(previous.key), replacement,
kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
}
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
}
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) {
return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming));
}
// Internal callers already validated both records. Keep validation at the
// public boundary and on the merged result without cloning both inputs again.
function mergeNormalizedRecords(oldRecord, newRecord) {
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 sameOptionIdentities = oldRecord.options.length > 0 && oldRecord.options.every((option) => option.id) &&
unique(oldRecord.options.map((option) => option.id)).length === oldRecord.options.length &&
sameArray(oldRecord.options.map((option) => JSON.stringify([option.id, comparableAnswer(option.text, oldRecord.type)])),
newRecord.options.map((option) => JSON.stringify([option.id, comparableAnswer(option.text, newRecord.type)])));
const exactIdsDiffer = sameOptionIdentities && oldRecord.correctOptionIds.length && newRecord.correctOptionIds.length &&
!sameArray(oldRecord.correctOptionIds, newRecord.correctOptionIds);
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' &&
(answerSignature(oldRecord) !== answerSignature(newRecord) || exactIdsDiffer);
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) ? mergeNormalizedRecords(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()]);
const signatures = new WeakMap();
const signature = record => {
if (!signatures.has(record)) signatures.set(record, contentSignature(record));
return signatures.get(record);
};
const byBankAndId = new Map();
const positions = new Map();
for (const [index, record] of [...records.values()].entries()) {
positions.set(record.key, index);
if (!record.questionId) continue;
const key = JSON.stringify([bankIdentity(record), record.questionId]);
if (!byBankAndId.has(key)) byBankAndId.set(key, []);
byBankAndId.get(key).push(record.key);
}
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue;
const queued = new Set(), knownAliases = new Set(), candidates = [];
let cursor = -1;
const enqueue = aliases => {
for (const alias of aliases) {
if (knownAliases.has(alias)) continue;
knownAliases.add(alias);
for (const key of byBankAndId.get(JSON.stringify([bankIdentity(target), alias])) || []) {
if (key === target.key || queued.has(key) || positions.get(key) <= cursor) continue;
queued.add(key);
// Retain the old Map traversal order, including when a merged
// record introduces further aliases during this pass.
let low = 0, high = candidates.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (positions.get(candidates[middle]) < positions.get(key)) low = middle + 1;
else high = middle;
}
candidates.splice(low, 0, key);
}
}
};
enqueue(records.get(target.key).questionIdAliases || []);
while (candidates.length) {
const key = candidates.shift();
cursor = positions.get(key);
const other = records.get(key);
if (!other) continue;
const current = records.get(target.key);
if (signature(current) !== signature(other)) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
const merged = foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence });
records.set(target.key, merged);
records.delete(other.key);
enqueue(merged.questionIdAliases || []);
}
}
const repairedContents = new Set([...records.values()].filter(record => record.questionIdAliases?.length > 1)
.map(record => JSON.stringify([bankIdentity(record), signature(record)])));
for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
if (repairedContents.has(JSON.stringify([bankIdentity(record), signature(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 && sameBank(a, b) &&
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
const identifiedByContent = new Map();
for (const record of identified) {
const key = signature(record);
if (!identifiedByContent.has(key)) identifiedByContent.set(key, []);
identifiedByContent.get(key).push(record);
}
const candidates = new Map(), candidateCounts = new Map();
for (const record of anonymous) {
const matches = (identifiedByContent.get(signature(record)) || []).filter(other => possiblePair(record, other));
candidates.set(record.key, matches);
for (const match of matches) candidateCounts.set(match.key, (candidateCounts.get(match.key) || 0) + 1);
}
for (const record of anonymous) {
const matches = candidates.get(record.key);
if (matches.length !== 1) continue;
const target = matches[0];
if (candidateCounts.get(target.key) !== 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) ? mergeNormalizedRecords(promoted, currentTarget) : mergeNormalizedRecords(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'));
if (group.length === 1 && first.bankId === meta.bankId && first.bankName === meta.bankName && first.questionId === meta.questionId &&
(meta.contentOccurrence == null || meta.contentOccurrence === first.contentOccurrence) &&
(!own(meta, 'questionIdAliases') || JSON.stringify(meta.questionIdAliases) === JSON.stringify(first.questionIdAliases))) return first;
let result;
for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeNormalizedRecords(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;
entry.signature = contentSignature(record);
entry.bank = bankIdentity(record);
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId;
if (explicit) entry.batch = `${entry.bank}:${explicit}`;
else if (entry.occurrence == null) {
const source = listSource(record);
if (!source) continue;
const key = JSON.stringify([entry.bank, 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, entry.signature]);
entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1);
}
const groups = new Map();
const byContent = new Map();
const byContentAlias = new Map();
const indexAliases = (key, entry) => {
if (!byContentAlias.has(entry.content)) byContentAlias.set(entry.content, new Map());
const aliases = byContentAlias.get(entry.content);
for (const id of [entry.record.questionId, ...(entry.record.questionIdAliases || [])]) {
if (!id) continue;
if (!aliases.has(id)) aliases.set(id, new Set());
aliases.get(id).add(key);
}
};
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([entry.bank, entry.signature]);
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);
indexAliases(key, entry);
}
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 = byContentAlias.get(entry.content)?.get(entry.record.questionId);
if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value;
else if (matches?.size === 1) key = matches.values().next().value;
else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`;
add(key, entry);
if (matches?.has(key)) indexAliases(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 completeIdentity = record.type === current.type && record.type !== 'unknown' && record.options.length > 0 &&
record.options.length === current.options.length && record.correctOptionIds.length > 0 &&
record.options.every(option => option.id) && current.options.every(option => option.id) &&
new Set(record.options.map(option => option.id)).size === record.options.length &&
new Set(current.options.map(option => option.id)).size === current.options.length &&
sameArray(record.options.map(option => JSON.stringify([option.id, comparableAnswer(option.text, record.type)])),
current.options.map(option => JSON.stringify([option.id, comparableAnswer(option.text, record.type)])));
if (completeIdentity) {
const selected = record.correctOptionIds.map(id => current.options.find(option => option.id === id));
if (selected.some(option => !option)) return result('unmapped');
if (unique(current.options.map(option => option.label)).length !== current.options.length) return result('ambiguous');
return result('matched', selected.map(option => option.label));
}
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) 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');
}
if (candidates.length > 1) {
const originalGroup = record.options.filter((option) => comparableAnswer(option.text, record.type) === expected);
// Repeated text is safe only when every occurrence is explicitly correct.
if (record.type !== 'multiple' || originalCorrect.length !== originalGroup.length || originalCorrect.length !== candidates.length) return result('ambiguous');
labels.push(...candidates.map((option) => option.label));
} else 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, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;