fix: retain question images and repair evidenced legacy records
This commit is contained in:
+134
-5
@@ -84,7 +84,7 @@ const QBCore = (() => {
|
||||
if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项';
|
||||
else derivedTexts.push(answerText(matches[0].text, type));
|
||||
}
|
||||
if (!inconsistency && answerTexts.length && !sameArray(answerTexts.map((value) => comparableAnswer(value, type)), derivedTexts.map((value) => comparableAnswer(value, type)))) {
|
||||
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);
|
||||
@@ -120,10 +120,128 @@ const QBCore = (() => {
|
||||
}
|
||||
|
||||
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 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 = existingBank.find(record => signature(record) === 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) ||
|
||||
existingBank.filter(record => record.questionIndex === current.questionIndex).length !== 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: existingInput[existing.findIndex(record => record.key === previous.key)], replacement,
|
||||
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 };
|
||||
}
|
||||
@@ -153,7 +271,14 @@ const QBCore = (() => {
|
||||
const base = { ...newRecord, ...metadata, explanation };
|
||||
const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts];
|
||||
const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict';
|
||||
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
|
||||
const 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的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案';
|
||||
@@ -428,7 +553,6 @@ const QBCore = (() => {
|
||||
for (const value of answerTexts) {
|
||||
const expected = comparableAnswer(value, record.type);
|
||||
const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected);
|
||||
if (candidates.length > 1) return result('ambiguous');
|
||||
if (!candidates.length) return result('unmapped');
|
||||
const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected);
|
||||
for (const original of originalCorrect) {
|
||||
@@ -436,7 +560,12 @@ const QBCore = (() => {
|
||||
if (idCandidates.length > 1) return result('ambiguous');
|
||||
if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped');
|
||||
}
|
||||
labels.push(candidates[0].label);
|
||||
if (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);
|
||||
@@ -550,7 +679,7 @@ const QBCore = (() => {
|
||||
return records;
|
||||
}
|
||||
|
||||
return { normalizeRecord, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
|
||||
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
|
||||
})();
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
|
||||
|
||||
Reference in New Issue
Block a user