fix: retain question images and repair evidenced legacy records

This commit is contained in:
suanyuit-cyber
2026-10-29 22:40:38 +08:00
parent 15fa009f9e
commit c3edf3975e
45 changed files with 2778 additions and 103 deletions
+134 -5
View File
@@ -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;