557 lines
34 KiB
JavaScript
557 lines
34 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(answerTexts.map((value) => comparableAnswer(value, type)), 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) {
|
||
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}`)));
|
||
}
|
||
|
||
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) {
|
||
const oldRecord = normalizeRecord(existing);
|
||
const newRecord = normalizeRecord(incoming);
|
||
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 answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
|
||
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) ? mergeRecord(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()]);
|
||
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
|
||
if (!records.has(target.key)) continue;
|
||
for (const other of [...records.values()]) {
|
||
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue;
|
||
const current = records.get(target.key);
|
||
const aliases = new Set(current.questionIdAliases || []);
|
||
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
|
||
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
|
||
records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }));
|
||
records.delete(other.key);
|
||
}
|
||
}
|
||
for (const record of [...records.values()]) {
|
||
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
|
||
if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, 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 && a.type === b.type && sameBank(a, b) && sameContent(a, b) &&
|
||
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
|
||
const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))]));
|
||
for (const record of anonymous) {
|
||
const matches = candidates.get(record.key);
|
||
if (matches.length !== 1) continue;
|
||
const target = matches[0];
|
||
if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 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) ? mergeRecord(promoted, currentTarget) : mergeRecord(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'));
|
||
let result;
|
||
for (const record of ordered) {
|
||
const rebased = normalizeRecord({ ...record, ...meta });
|
||
result = result ? mergeRecord(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;
|
||
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
|
||
const explicit = record.source.batchId || record.answerSource?.batchId;
|
||
if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`;
|
||
else if (entry.occurrence == null) {
|
||
const source = listSource(record);
|
||
if (!source) continue;
|
||
const key = JSON.stringify([bankIdentity(record), 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, contentSignature(entry.record)]);
|
||
entry.occurrence = counters.get(key) || 0;
|
||
counters.set(key, entry.occurrence + 1);
|
||
}
|
||
const groups = new Map();
|
||
const byContent = new Map();
|
||
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([bankIdentity(entry.record), contentSignature(entry.record)]);
|
||
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);
|
||
}
|
||
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 = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId)));
|
||
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0];
|
||
else if (matches.length === 1) key = matches[0];
|
||
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
|
||
else key = `unresolved:${entry.record.key}`;
|
||
add(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 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 > 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) {
|
||
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');
|
||
}
|
||
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, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
|
||
})();
|
||
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
|