152 lines
9.8 KiB
JavaScript
152 lines
9.8 KiB
JavaScript
/* Exact answer matching and unified local search. Roles remain source metadata. */
|
||
(function (root) {
|
||
'use strict';
|
||
const cache = new WeakMap();
|
||
|
||
function normalize(value) {
|
||
// Strict answer identity: MPa/mPa, m²/m2 and Ⅰ/I are distinct.
|
||
return String(value == null ? '' : value)
|
||
.replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
|
||
.replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
// Search is intentionally more tolerant; these keys never authorize clicks.
|
||
function searchNormalize(value) {
|
||
return normalize(value).normalize('NFKC').replace(/[−﹣]/g, '-').toLowerCase();
|
||
}
|
||
|
||
function courseNormalize(value) { return searchNormalize(value); }
|
||
|
||
function normalizeType(value) {
|
||
const type = String(value || '').trim().replace(/^[\[【]|[\]】]$/g, '').replace(/题$/, '');
|
||
return ({ 单选: '单选', 单项选择: '单选', single: '单选', radio: '单选', 多选: '多选', 多项选择: '多选', multiple: '多选', checkbox: '多选', 判断: '判断', 判断正误: '判断', boolean: '判断' })[type.toLowerCase()] || type;
|
||
}
|
||
|
||
function optionKey(options) { return JSON.stringify((options || []).map(option => normalize(option.text)).sort()); }
|
||
function questionKey(record) { return JSON.stringify([normalizeType(record.type), normalize(record.question)]); }
|
||
function correctKey(record) { return JSON.stringify((record.correctTexts || []).map(normalize).sort()); }
|
||
function groupKey(record) { return questionKey(record) + '\n' + optionKey(record.options); }
|
||
function normalizedLabel(value) { return String(value || '').normalize('NFKC').trim().toUpperCase().replace(/[.、::)]$/, ''); }
|
||
|
||
function getIndex(snapshot) {
|
||
if (!snapshot || !Array.isArray(snapshot.records)) return new Map();
|
||
const saved = cache.get(snapshot);
|
||
if (saved && saved.revision === snapshot.revision && saved.records === snapshot.records) return saved.index;
|
||
const index = new Map();
|
||
snapshot.records.forEach(record => {
|
||
const key = groupKey(record);
|
||
if (!index.has(key)) index.set(key, []);
|
||
index.get(key).push(record);
|
||
});
|
||
cache.set(snapshot, { revision: snapshot.revision, records: snapshot.records, index });
|
||
return index;
|
||
}
|
||
|
||
function sourceIdentity(value) {
|
||
if (!value) return '';
|
||
try {
|
||
const url = new URL(value, 'https://local.invalid');
|
||
const route = url.hash.startsWith('#/') ? new URL(url.hash.slice(1), url.origin) : url;
|
||
for (const params of [route.searchParams, url.searchParams]) {
|
||
for (const [key, id] of params) {
|
||
if (/^(course_?id|exam_?id|paper_?id|training_?id|lesson_?id)$/i.test(key) && id) return key.toLowerCase().replace(/_/g, '') + ':' + id;
|
||
}
|
||
}
|
||
const exam = route.pathname.match(/\/exam\/([^/]+)\/(?:questions|exercise)\/?$/i);
|
||
if (exam) return 'examid:' + decodeURIComponent(exam[1]);
|
||
// A source path works after a domain/IP change, while title parameters do not identify a course.
|
||
const params = new URLSearchParams(route.search);
|
||
['title', 'flag', 'name'].forEach(key => params.delete(key));
|
||
params.sort();
|
||
return route.pathname + (params.size ? '?' + params.toString() : '');
|
||
} catch (_) { return String(value); }
|
||
}
|
||
|
||
function result(status, reason, records, labels, texts) {
|
||
return { status, reason, records: records || [], correctLabels: labels || [], correctTexts: texts || [] };
|
||
}
|
||
|
||
function match(question, context, snapshot) {
|
||
context = context || {};
|
||
if (!question || !['单选', '多选', '判断'].includes(normalizeType(question.type))) return result('unsupported', '当前题型暂不支持自动选择');
|
||
if (!normalize(question.question) || !Array.isArray(question.options) || question.options.length < 2) return result('unsupported', '题干或选项尚未完整加载');
|
||
if ((question.questionImages && question.questionImages.length) || question.options.some(option => !normalize(option.text) || (option.images && option.images.length))) return result('unsupported', '含图片的题目需要人工核对');
|
||
const currentTexts = question.options.map(option => normalize(option.text));
|
||
const currentLabels = question.options.map(option => normalizedLabel(option.label));
|
||
if (new Set(currentTexts).size !== currentTexts.length || new Set(currentLabels).size !== currentLabels.length || currentLabels.some(label => !/^[A-H]$/.test(label))) return result('unsupported', '当前选项文字或字母重复,无法唯一映射');
|
||
let records = getIndex(snapshot).get(groupKey(question)) || [];
|
||
if (!records.length) return result('missing', '统一题库没有题型、题干和全部选项均一致的记录');
|
||
if (Array.isArray(context.courses)) {
|
||
const allowed = new Set(context.courses.map(courseNormalize).filter(Boolean));
|
||
records = records.filter(record => allowed.has(courseNormalize(record.course)));
|
||
if (!records.length) return result('missing', '已确认的题库主题中没有完全一致的题目记录');
|
||
}
|
||
let sourceMatched = false;
|
||
const identity = sourceIdentity(context.sourceUrl);
|
||
if (identity) {
|
||
const exactSource = records.filter(record => record.sourceUrl && sourceIdentity(record.sourceUrl) === identity);
|
||
if (exactSource.length) { records = exactSource; sourceMatched = true; }
|
||
}
|
||
const course = courseNormalize(context.course);
|
||
if (course) {
|
||
const exactCourse = records.filter(record => courseNormalize(record.course) === course);
|
||
if (exactCourse.length) { records = exactCourse; sourceMatched = true; }
|
||
}
|
||
const answers = new Set(records.map(correctKey));
|
||
if (answers.size > 1) return result('conflict', '相同题干与选项存在不同答案;当前课程来源未能唯一确定', records);
|
||
const wanted = records[0].correctTexts || [];
|
||
const wantedKeys = wanted.map(normalize);
|
||
if (!wantedKeys.length || new Set(wantedKeys).size !== wantedKeys.length || wantedKeys.some(text => !currentTexts.includes(text))) return result('unsupported', '题库参考答案无法唯一对应当前选项', records);
|
||
const selected = question.options.filter(option => wantedKeys.includes(normalize(option.text)));
|
||
if (normalizeType(question.type) !== '多选' && selected.length !== 1) return result('unsupported', '单选/判断题的参考答案数量不正确', records);
|
||
// Keep label/text pairs in current-page order, but obtain each text from bank evidence.
|
||
const evidenceTexts = selected.map(option => wanted.find(text => normalize(text) === normalize(option.text)));
|
||
return result('matched', sourceMatched ? '课程来源、题干及选项完全匹配' : '题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), evidenceTexts);
|
||
}
|
||
|
||
function search(query, settings, snapshot) {
|
||
settings = settings || {};
|
||
const empty = reason => ({ items: [], total: 0, reason, role: '', query: String(query || ''), hasMore: false });
|
||
const fullQuery = searchNormalize(query);
|
||
if (!fullQuery) return empty('请输入题干、关键词或选项文字');
|
||
const tokens = fullQuery.split(/\s+/).filter(Boolean);
|
||
const course = courseNormalize(settings.course);
|
||
const records = snapshot && Array.isArray(snapshot.records) ? snapshot.records : [];
|
||
const groups = new Map(), conflicts = new Map();
|
||
records.forEach(record => {
|
||
if (course && courseNormalize(record.course) !== course) return;
|
||
if (settings.sourceUrl && sourceIdentity(record.sourceUrl) !== sourceIdentity(settings.sourceUrl)) return;
|
||
const baseKey = groupKey(record), answerKey = correctKey(record), key = baseKey + '\n' + answerKey;
|
||
if (!conflicts.has(baseKey)) conflicts.set(baseKey, new Set());
|
||
conflicts.get(baseKey).add(answerKey);
|
||
if (!groups.has(key)) groups.set(key, { ...record, sources: [], conflict: false, sourceCount: 0, _baseKey: baseKey, _sources: new Set() });
|
||
const group = groups.get(key);
|
||
const source = { role: record.role || '', course: record.course, sourceUrl: record.sourceUrl || '', sourceFile: record.sourceFile || '', number: record.number };
|
||
const sourceKey = JSON.stringify(source);
|
||
if (!group._sources.has(sourceKey)) { group._sources.add(sourceKey); group.sources.push(source); group.sourceCount++; }
|
||
});
|
||
const items = [];
|
||
groups.forEach(group => {
|
||
const stem = searchNormalize(group.question);
|
||
const optionText = (group.options || []).map(option => searchNormalize(option.text)).join('\n');
|
||
const answerText = (group.correctTexts || []).map(searchNormalize).join('\n');
|
||
const haystack = stem + '\n' + optionText + '\n' + answerText;
|
||
if (!tokens.every(token => haystack.includes(token))) return;
|
||
let score = stem === fullQuery ? 10000 : stem.includes(fullQuery) ? 5000 : 0;
|
||
tokens.forEach(token => { score += stem.includes(token) ? 100 : optionText.includes(token) ? 30 : 10; });
|
||
group.conflict = conflicts.get(group._baseKey).size > 1;
|
||
delete group._baseKey; delete group._sources;
|
||
items.push({ ...group, score });
|
||
});
|
||
items.sort((left, right) => right.score - left.score || left.question.localeCompare(right.question, 'zh-CN') || left.course.localeCompare(right.course, 'zh-CN') || left.number - right.number);
|
||
const total = items.length;
|
||
const limit = Math.max(1, Math.min(100, Number(settings.limit) || 20));
|
||
const offset = Math.max(0, Math.floor(Number(settings.offset) || 0));
|
||
return { items: items.slice(offset, offset + limit), total, role: '', query: String(query), reason: total ? '' : '统一题库未找到符合条件的题目', hasMore: offset + limit < total };
|
||
}
|
||
|
||
const api = Object.freeze({ version: '3.0.0', normalize, searchNormalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
|
||
root.YianAnswerMatcher = api;
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|