feat: publish verified five-bank question dataset

This commit is contained in:
suanyuit-cyber
2026-10-29 23:09:32 +08:00
parent c3edf3975e
commit 26e0a920b6
26 changed files with 109610 additions and 1560 deletions
+70 -1
View File
@@ -242,6 +242,62 @@ const QBCore = (() => {
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();
for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current);
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue;
const candidates = bank.filter(record => hasEntity(record) && signature(record) === 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 (bank.some(record => (signature(record) === key || record.questionIndex === current.questionIndex) &&
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: existingInput[existing.findIndex(record => record.key === 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 };
}
@@ -549,6 +605,19 @@ const QBCore = (() => {
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);
@@ -679,7 +748,7 @@ const QBCore = (() => {
return records;
}
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;