852 lines
55 KiB
JavaScript
852 lines
55 KiB
JavaScript
/* 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 = { '×': '×', '±': '±' };
|
||
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;
|