daily-question 2.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +38 -26
- package/client.js +250 -144
- package/cordis.patch.yml +1 -1
- package/icon.svg +10 -7
- package/index.js +6 -6
- package/lib/bank.js +53 -36
- package/lib/exam-dates.js +101 -67
- package/lib/generate.js +62 -25
- package/lib/generation-history.js +8 -0
- package/lib/paper-types.js +47 -0
- package/lib/reference-reader.js +11 -8
- package/lib/references.js +72 -26
- package/lib/research.js +22 -13
- package/lib/routes.js +62 -95
- package/lib/service.js +73 -72
- package/lib/store.js +3 -8
- package/lib/subjects.js +12 -0
- package/package.json +2 -2
- package/vendor/mathjax/a11y/assistive-mml.js +1 -0
- package/vendor/mathjax/input/tex/extensions/action.js +1 -0
- package/vendor/mathjax/input/tex/extensions/ams.js +1 -0
- package/vendor/mathjax/input/tex/extensions/amscd.js +1 -0
- package/vendor/mathjax/input/tex/extensions/autoload.js +1 -0
- package/vendor/mathjax/input/tex/extensions/bbm.js +1 -0
- package/vendor/mathjax/input/tex/extensions/bboldx.js +1 -0
- package/vendor/mathjax/input/tex/extensions/bbox.js +1 -0
- package/vendor/mathjax/input/tex/extensions/begingroup.js +1 -0
- package/vendor/mathjax/input/tex/extensions/boldsymbol.js +1 -0
- package/vendor/mathjax/input/tex/extensions/braket.js +1 -0
- package/vendor/mathjax/input/tex/extensions/bussproofs.js +1 -0
- package/vendor/mathjax/input/tex/extensions/cancel.js +1 -0
- package/vendor/mathjax/input/tex/extensions/cases.js +1 -0
- package/vendor/mathjax/input/tex/extensions/centernot.js +1 -0
- package/vendor/mathjax/input/tex/extensions/color.js +1 -0
- package/vendor/mathjax/input/tex/extensions/colortbl.js +1 -0
- package/vendor/mathjax/input/tex/extensions/colorv2.js +1 -0
- package/vendor/mathjax/input/tex/extensions/configmacros.js +1 -0
- package/vendor/mathjax/input/tex/extensions/dsfont.js +1 -0
- package/vendor/mathjax/input/tex/extensions/empheq.js +1 -0
- package/vendor/mathjax/input/tex/extensions/enclose.js +1 -0
- package/vendor/mathjax/input/tex/extensions/extpfeil.js +1 -0
- package/vendor/mathjax/input/tex/extensions/fontsizev3.js +1 -0
- package/vendor/mathjax/input/tex/extensions/gensymb.js +1 -0
- package/vendor/mathjax/input/tex/extensions/html.js +1 -0
- package/vendor/mathjax/input/tex/extensions/mathtools.js +1 -0
- package/vendor/mathjax/input/tex/extensions/mhchem.js +34 -0
- package/vendor/mathjax/input/tex/extensions/newcommand.js +1 -0
- package/vendor/mathjax/input/tex/extensions/noerrors.js +1 -0
- package/vendor/mathjax/input/tex/extensions/noundefined.js +1 -0
- package/vendor/mathjax/input/tex/extensions/physics.js +1 -0
- package/vendor/mathjax/input/tex/extensions/require.js +1 -0
- package/vendor/mathjax/input/tex/extensions/setoptions.js +1 -0
- package/vendor/mathjax/input/tex/extensions/tagformat.js +1 -0
- package/vendor/mathjax/input/tex/extensions/texhtml.js +1 -0
- package/vendor/mathjax/input/tex/extensions/textcomp.js +1 -0
- package/vendor/mathjax/input/tex/extensions/textmacros.js +1 -0
- package/vendor/mathjax/input/tex/extensions/unicode.js +1 -0
- package/vendor/mathjax/input/tex/extensions/units.js +1 -0
- package/vendor/mathjax/input/tex/extensions/upgreek.js +1 -0
- package/vendor/mathjax/input/tex/extensions/verb.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/PUA.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/accents-b-i.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/accents.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/arabic.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/arrows.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/braille-d.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/braille.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/calligraphic.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/cherokee.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/cyrillic-ss.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/cyrillic.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/devanagari.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/double-struck.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/fraktur.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/greek-ss.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/greek.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/hebrew.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/latin-b.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/latin-bi.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/latin-i.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/latin.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/marrows.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/math.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/monospace-ex.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/monospace-l.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/monospace.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/mshapes.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/phonetics-ss.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/phonetics.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/sans-serif-b.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/sans-serif-bi.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/sans-serif-ex.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/sans-serif-i.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/sans-serif-r.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/sans-serif.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/script.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/shapes.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/symbols-b-i.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/symbols.js +1 -0
- package/vendor/mathjax/newcm/svg/dynamic/variants.js +1 -0
- package/vendor/mathjax/tex-svg.js +1 -34
package/cordis.patch.yml
CHANGED
package/icon.svg
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="考研每日一题">
|
|
2
2
|
<defs>
|
|
3
|
-
<linearGradient id="
|
|
4
|
-
<stop offset="0" stop-color="#
|
|
5
|
-
<stop offset="1" stop-color="#
|
|
3
|
+
<linearGradient id="dq-blue" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#5676ff"/>
|
|
5
|
+
<stop offset="1" stop-color="#3b55e8"/>
|
|
6
6
|
</linearGradient>
|
|
7
7
|
</defs>
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#dq-blue)"/>
|
|
9
|
+
<g fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round">
|
|
10
|
+
<rect x="15.5" y="20" width="33" height="30" rx="6.5" stroke-width="3.6"/>
|
|
11
|
+
<path d="M23 15.5V23M41 15.5V23" stroke-width="3.6"/>
|
|
12
|
+
<path d="m27.4 33.2 5.5-3.9v13.3m-4.4 0h9.2" stroke-width="4"/>
|
|
13
|
+
</g>
|
|
11
14
|
</svg>
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { registerRoutes } from './lib/routes.js'
|
|
|
6
6
|
*
|
|
7
7
|
* 职责:题库与学习数据的持久化、在线出题 + 独立答案校验、
|
|
8
8
|
* 答案详解的固定版本、按题目隔离的追问线程、考研日期核验,
|
|
9
|
-
*
|
|
9
|
+
* 以及通过宿主 Connection 的鉴权接口向本包的 Client 半提供这些能力。
|
|
10
10
|
*
|
|
11
11
|
* 界面全部由 client.js 以 Slot 组件渲染;这里不提供 HTML 页面。
|
|
12
12
|
*/
|
|
@@ -38,13 +38,13 @@ export function apply(ctx) {
|
|
|
38
38
|
return () => { stopped = true; service.dispose() }
|
|
39
39
|
}, `${PREFIX}.store`)
|
|
40
40
|
|
|
41
|
-
ctx.inject(['
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
logger.warn?.('
|
|
41
|
+
ctx.inject(['connection'], (scoped) => {
|
|
42
|
+
const connection = scoped.connection
|
|
43
|
+
if (typeof connection?.fetch?.register !== 'function') {
|
|
44
|
+
logger.warn?.('宿主 Connection Fetch 不可用,界面接口没有启动')
|
|
45
45
|
return undefined
|
|
46
46
|
}
|
|
47
|
-
return registerRoutes(
|
|
47
|
+
return registerRoutes(connection, service, logger)
|
|
48
48
|
})
|
|
49
49
|
}
|
|
50
50
|
|
package/lib/bank.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CHOICE_TYPES, QUESTION_TYPE_LABEL } from './subjects.js'
|
|
1
|
+
import { CHOICE_TYPES, QUESTION_TYPE_LABEL, hasChoiceOptions, validChoiceKey } from './subjects.js'
|
|
2
2
|
import { extractPdfTextAsync } from './pdf.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -9,13 +9,15 @@ import { extractPdfTextAsync } from './pdf.js'
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const OPTION_LINE = /^\s*(?:[((]\s*([A-H])\s*[))]|([A-H])\s*[.、.::))])\s*(.+)$/
|
|
12
|
-
const QUESTION_START = /^\s*(?:#{1,6}\s*)?(?:第\s*)?(\d{1,4})\s*(
|
|
12
|
+
const QUESTION_START = /^\s*((?:#{1,6}\s*)?(?:第\s*)?)(\d{1,4})\s*(题|[.、.))])\s*(.*)$/
|
|
13
13
|
const LABEL_LINE = /^\s*(?:【?\s*(答案|参考答案|正确答案|解析|详解|答案详解|【解析】|分析|知识点|考点|难度|题型|答|解)\s*】?\s*[::]\s*)(.*)$/
|
|
14
14
|
const HARD_SEPARATOR = /^\s*(?:-{3,}|\*{3,}|={3,})\s*$/
|
|
15
15
|
const BRACKET_MARK = /^\s*【\s*(题目|题干|问题)\s*】\s*(.*)$/
|
|
16
16
|
const TYPE_WORDS = [
|
|
17
|
-
['
|
|
17
|
+
['cloze', ['完形填空', '英语知识运用']],
|
|
18
|
+
['reading', ['阅读理解']],
|
|
18
19
|
['multiple', ['多项选择', '多选']],
|
|
20
|
+
['single', ['单项选择', '单选', '选择题']],
|
|
19
21
|
['fill', ['填空']],
|
|
20
22
|
['solution', ['解答', '计算', '证明', '综合应用', '算法设计', '综合题']],
|
|
21
23
|
['analysis', ['分析题', '材料分析']],
|
|
@@ -24,6 +26,12 @@ const TYPE_WORDS = [
|
|
|
24
26
|
['shortanswer', ['简答']],
|
|
25
27
|
]
|
|
26
28
|
|
|
29
|
+
function answerSection(name) {
|
|
30
|
+
if (/解析|详解|^(分析|解)$/.test(name)) return 'explanation'
|
|
31
|
+
if (/答案|^答$/.test(name)) return 'answer'
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/** 判断一行是否处于 ``` 代码围栏内,用于避免在代码块里切分题目。 */
|
|
28
36
|
function fenceTracker() {
|
|
29
37
|
let inside = false
|
|
@@ -41,29 +49,42 @@ function splitBlocks(text) {
|
|
|
41
49
|
const isFenced = fenceTracker()
|
|
42
50
|
const blocks = []
|
|
43
51
|
let current = []
|
|
52
|
+
let inAnswer = false
|
|
53
|
+
let questionNumber = null
|
|
54
|
+
let blankBefore = false
|
|
44
55
|
const push = () => {
|
|
45
56
|
const body = current.join('\n').trim()
|
|
46
57
|
if (body) blocks.push(body)
|
|
47
58
|
current = []
|
|
59
|
+
inAnswer = false
|
|
60
|
+
questionNumber = null
|
|
48
61
|
}
|
|
49
62
|
for (const line of lines) {
|
|
50
63
|
const fenced = isFenced(line)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
push()
|
|
54
|
-
const rest = BRACKET_MARK.exec(line)[2]
|
|
55
|
-
current = [rest].filter(Boolean)
|
|
56
|
-
continue
|
|
57
|
-
}
|
|
58
|
-
if (HARD_SEPARATOR.test(line)) {
|
|
59
|
-
push()
|
|
60
|
-
continue
|
|
61
|
-
}
|
|
64
|
+
const bracket = fenced ? null : BRACKET_MARK.exec(line)
|
|
65
|
+
if (bracket || (!fenced && HARD_SEPARATOR.test(line))) {
|
|
62
66
|
push()
|
|
63
|
-
|
|
67
|
+
if (bracket?.[2]) current.push(bracket[2])
|
|
68
|
+
blankBefore = false
|
|
64
69
|
continue
|
|
65
70
|
}
|
|
71
|
+
const numbered = fenced ? null : QUESTION_START.exec(line)
|
|
72
|
+
if (numbered) {
|
|
73
|
+
const [, prefix, digits, suffix, stem] = numbered
|
|
74
|
+
const number = Number(digits)
|
|
75
|
+
const explicit = prefix || suffix === '题'
|
|
76
|
+
// shortcut: 裸数字题号与解析步骤有歧义,低置信度时请用“第 N 题”或分隔线。
|
|
77
|
+
const nextQuestion = blankBefore && number === questionNumber + 1
|
|
78
|
+
&& (/^(?:已知|设|下列|试问|证明[::]|计算[::])/.test(stem) || /[??]$/.test(stem))
|
|
79
|
+
if (explicit || !inAnswer || nextQuestion) {
|
|
80
|
+
push()
|
|
81
|
+
questionNumber = number
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const label = fenced ? null : LABEL_LINE.exec(line)
|
|
85
|
+
if (label && answerSection(label[1].replace(/[【】]/g, ''))) inAnswer = true
|
|
66
86
|
current.push(line)
|
|
87
|
+
blankBefore = !line.trim()
|
|
67
88
|
}
|
|
68
89
|
push()
|
|
69
90
|
return blocks
|
|
@@ -121,14 +142,10 @@ export function parseBlock(block, index) {
|
|
|
121
142
|
if (label) {
|
|
122
143
|
const name = label[1].replace(/[【】]/g, '')
|
|
123
144
|
const rest = label[2].trim()
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
if (name.includes('解析') || name.includes('详解') || name === '分析' || name === '解') {
|
|
130
|
-
section = 'explanation'
|
|
131
|
-
if (rest) sections.explanation.push(entry)
|
|
145
|
+
const contentSection = answerSection(name)
|
|
146
|
+
if (contentSection) {
|
|
147
|
+
section = contentSection
|
|
148
|
+
if (rest) sections[section].push({ ...entry, text: rest })
|
|
132
149
|
continue
|
|
133
150
|
}
|
|
134
151
|
if (name === '知识点' || name === '考点') {
|
|
@@ -153,9 +170,9 @@ export function parseBlock(block, index) {
|
|
|
153
170
|
sections[section].push(entry)
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
const stemEntries = sections.stem.map((entry) => ({
|
|
173
|
+
const stemEntries = sections.stem.map((entry, index) => ({
|
|
157
174
|
...entry,
|
|
158
|
-
text:
|
|
175
|
+
text: index === 0 && !entry.fenced ? entry.text.replace(QUESTION_START, '$4').trim() : entry.text,
|
|
159
176
|
}))
|
|
160
177
|
const optionLines = stemEntries.filter((entry) => !entry.fenced && OPTION_LINE.test(entry.text))
|
|
161
178
|
const stemBody = stemEntries.filter((entry) => entry.fenced || !OPTION_LINE.test(entry.text))
|
|
@@ -184,21 +201,16 @@ export function parseBlock(block, index) {
|
|
|
184
201
|
const issues = []
|
|
185
202
|
if (!stemBody) issues.push('未识别到题干')
|
|
186
203
|
else if (stemBody.length < 6) issues.push('题干过短,可能识别不完整')
|
|
187
|
-
if (CHOICE_TYPES.has(type) && options
|
|
204
|
+
if (CHOICE_TYPES.has(type) && !hasChoiceOptions(options)) issues.push('选择题需要至少两个不同且有效的选项')
|
|
188
205
|
if (!answerRaw) issues.push('没有参考答案(标记为缺少参考答案)')
|
|
189
206
|
if (!explanation) issues.push('没有解析(可稍后补充)')
|
|
190
207
|
if (optionLines.length && !options.length) issues.push('选项行格式无法识别')
|
|
191
208
|
|
|
192
209
|
let answerKey = ''
|
|
193
210
|
const answerLetters = CHOICE_TYPES.has(type) ? lettersOf(answerRaw) : ''
|
|
194
|
-
if (
|
|
195
|
-
if (answerLetters
|
|
196
|
-
|
|
197
|
-
} else if (type === 'single' && answerLetters.length > 1) {
|
|
198
|
-
issues.push('参考答案给出多个选项,题型可能应为多项选择题')
|
|
199
|
-
} else if (answerLetters) {
|
|
200
|
-
answerKey = answerLetters
|
|
201
|
-
}
|
|
211
|
+
if (answerLetters) {
|
|
212
|
+
if (validChoiceKey(type, answerLetters, options)) answerKey = [...answerLetters].sort().join('')
|
|
213
|
+
else issues.push('参考答案与题型或选项不匹配,将由用户自行判定')
|
|
202
214
|
}
|
|
203
215
|
|
|
204
216
|
return {
|
|
@@ -219,6 +231,11 @@ export function parseBlock(block, index) {
|
|
|
219
231
|
}
|
|
220
232
|
}
|
|
221
233
|
|
|
234
|
+
export function isUsableBankItem(item) {
|
|
235
|
+
return typeof item.stem === 'string' && item.stem.trim().length >= 6
|
|
236
|
+
&& (!CHOICE_TYPES.has(item.type) || hasChoiceOptions(item.options))
|
|
237
|
+
}
|
|
238
|
+
|
|
222
239
|
/** 像标题、说明或分隔块的片段:不含选项/答案/解析,且不像一道题。 */
|
|
223
240
|
function isHeaderBlock(item) {
|
|
224
241
|
if (item.options.length || item.answer || item.explanation || item.knowledge.length) return false
|
|
@@ -259,8 +276,8 @@ export function parseBankText(text, { filename = '' } = {}) {
|
|
|
259
276
|
if (items.length === 1) {
|
|
260
277
|
warnings.push('只识别到 1 道题:如果这是多题题库,请用“题号 / --- / 【题目】”等分隔符分开每道题')
|
|
261
278
|
}
|
|
262
|
-
const usable = items.filter(
|
|
263
|
-
if (!usable) warnings.push('
|
|
279
|
+
const usable = items.filter(isUsableBankItem).length
|
|
280
|
+
if (!usable) warnings.push('没有可用题目:请检查题干及选择题选项是否完整')
|
|
264
281
|
if (filename) warnings.push(`来源文件:${filename}`)
|
|
265
282
|
return { items, warnings, usable }
|
|
266
283
|
}
|
package/lib/exam-dates.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { abortable } from './async.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* 考研日期的权威性处理。
|
|
3
5
|
*
|
|
@@ -269,93 +271,125 @@ export function computeCountdown(year, examInfo, today = todayISO()) {
|
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
function stripHtml(html) {
|
|
272
|
-
return
|
|
273
|
-
.replace(
|
|
274
|
-
.replace(/<style[\s\S]
|
|
274
|
+
return html
|
|
275
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
276
|
+
.replace(/<(head|title|script|style|nav|aside|footer|a)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
|
|
277
|
+
.replace(/\r?\n/g, ' ')
|
|
278
|
+
.replace(/<\/(?:p|div|li|tr|h[1-6])\s*>|<br\b[^>]*>/gi, '\n')
|
|
275
279
|
.replace(/<[^>]+>/g, ' ')
|
|
276
|
-
.replace(/ /
|
|
280
|
+
.replace(/&(?:nbsp|#160|#xa0);/gi, ' ')
|
|
277
281
|
.replace(/&/g, '&')
|
|
278
282
|
.replace(/</g, '<')
|
|
279
283
|
.replace(/>/g, '>')
|
|
280
|
-
.replace(/\s+/g, ' ')
|
|
281
284
|
}
|
|
282
285
|
|
|
283
|
-
function
|
|
286
|
+
function officialUrl(value) {
|
|
284
287
|
try {
|
|
285
|
-
const
|
|
286
|
-
|
|
288
|
+
const url = new URL(value)
|
|
289
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null
|
|
290
|
+
if (!OFFICIAL_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith('.' + host))) return null
|
|
291
|
+
url.hash = ''
|
|
292
|
+
return url.href
|
|
287
293
|
} catch {
|
|
288
|
-
return
|
|
294
|
+
return null
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
function examSessions(text) {
|
|
299
|
+
return Array.from(text.matchAll(/(\d{4})年?(?:(?:全国)?硕士研究生招生|考研|研考)/g), (match) => Number(match[1]))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function readExamDates(body, year) {
|
|
303
|
+
if (typeof body?.content !== 'string' || !['html', 'text'].includes(body.kind)) return null
|
|
304
|
+
const html = body.kind === 'html'
|
|
305
|
+
const title = html ? stripHtml(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(body.content)?.[1] || '').trim() : ''
|
|
306
|
+
const heading = html ? stripHtml(/<h([12])\b[^>]*>([\s\S]*?)<\/h\1\s*>/i.exec(body.content)?.[2] || '') : ''
|
|
307
|
+
const paragraphs = html ? stripHtml(body.content.replace(/<h([1-6])\b[^>]*>[\s\S]*?<\/h\1\s*>/gi, '<br>')) : body.content
|
|
308
|
+
const text = paragraphs.normalize('NFKC').replace(/[^\S\r\n]+/g, '')
|
|
309
|
+
const titleYears = examSessions(title.normalize('NFKC').replace(/\s+/g, ''))
|
|
310
|
+
const pageYears = titleYears.length ? titleYears : examSessions(heading.normalize('NFKC').replace(/\s+/g, '') + '\n' + text)
|
|
311
|
+
if (!pageYears.includes(year)) return null
|
|
312
|
+
|
|
313
|
+
const examYear = year - 1
|
|
314
|
+
const pattern = new RegExp(`初试时间(?:为|是|定于|安排在|调整为)?:?${examYear}年12月(\\d{1,2})日?(?:至|到|[-–—~])(?:(?:${examYear}年)?12月)?(\\d{1,2})日`, 'g')
|
|
315
|
+
let result = null
|
|
316
|
+
for (const sentence of text.split(/[\r\n。;;,,]+/)) {
|
|
317
|
+
const years = examSessions(sentence)
|
|
318
|
+
if (years.length && !years.includes(year)) continue
|
|
319
|
+
for (const match of sentence.matchAll(pattern)) {
|
|
320
|
+
const start = Number(match[1])
|
|
321
|
+
const end = Number(match[2])
|
|
322
|
+
if (start < 1 || end < start || end > 31) continue
|
|
323
|
+
const date = `${examYear}-12-${pad2(start)}`
|
|
324
|
+
const endDate = `${examYear}-12-${pad2(end)}`
|
|
325
|
+
if (result && (result.date !== date || result.endDate !== endDate)) return null
|
|
326
|
+
result = { date, endDate, title, snippet: match[0] }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return result
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 仅采信目标届次的官方正文明确给出的完整初试区间。 */
|
|
298
333
|
export async function verifyExamDateOnline({ year, web, signal, fetchLimit = 3 }) {
|
|
299
334
|
const checked = []
|
|
300
|
-
|
|
301
|
-
if (!web || typeof web.search !== 'function') {
|
|
335
|
+
if (typeof web?.search !== 'function' || typeof web?.fetch !== 'function') {
|
|
302
336
|
return { ok: false, reason: 'web 服务不可用,无法联网核验官方日期', checked }
|
|
303
337
|
}
|
|
338
|
+
if (!Number.isInteger(year) || year < 1001 || year > 9999) return { ok: false, reason: '考研届次无效', checked }
|
|
304
339
|
const expectYear = year - 1
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const official = sources.filter((item) => item && item.url && isOfficialHost(item.url)).slice(0, fetchLimit)
|
|
314
|
-
if (!official.length) {
|
|
315
|
-
return {
|
|
316
|
-
ok: false,
|
|
317
|
-
reason: `没有检索到 ${year} 考研在官方渠道(moe.gov.cn / chsi.com.cn / neea.edu.cn)发布的初试日期,保持“尚未核实/估算”状态`,
|
|
318
|
-
checked: sources.map((item) => item.url).slice(0, 8),
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
for (const source of official) {
|
|
323
|
-
checked.push(source.url)
|
|
340
|
+
const visited = new Set()
|
|
341
|
+
let searched = false
|
|
342
|
+
let searchError
|
|
343
|
+
for (const host of OFFICIAL_HOSTS) {
|
|
344
|
+
if (signal?.aborted) break
|
|
345
|
+
const subject = host === 'moe.gov.cn' ? '招生工作管理规定' : '招生考试'
|
|
346
|
+
const query = `site:${host} ${year}年全国硕士研究生${subject} 初试时间 ${expectYear}年12月`
|
|
347
|
+
let sources
|
|
324
348
|
try {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|| new RegExp(`${expectYear}年12月(\\d{1,2})日至`).exec(text)
|
|
329
|
-
if (!match) continue
|
|
330
|
-
const day = Number(match[1])
|
|
331
|
-
if (!(day >= 1 && day <= 31)) continue
|
|
332
|
-
const date = `${expectYear}-12-${pad2(day)}`
|
|
333
|
-
return {
|
|
334
|
-
ok: true,
|
|
335
|
-
record: {
|
|
336
|
-
date,
|
|
337
|
-
endDate: addDays(date, 1),
|
|
338
|
-
status: 'official',
|
|
339
|
-
source: source.title || source.url,
|
|
340
|
-
sourceUrl: source.url,
|
|
341
|
-
publishedAt: source.publishedAt || '',
|
|
342
|
-
verifiedAt: now,
|
|
343
|
-
method: 'online-search',
|
|
344
|
-
note: `由官方渠道页面核验:${source.url}`,
|
|
345
|
-
},
|
|
346
|
-
checked,
|
|
347
|
-
snippet: match[0],
|
|
348
|
-
}
|
|
349
|
+
const result = await abortable(() => web.search({ query, maxResults: 8 }, signal), signal)
|
|
350
|
+
searched = true
|
|
351
|
+
sources = Array.isArray(result?.sources) ? result.sources : []
|
|
349
352
|
} catch (error) {
|
|
350
|
-
|
|
353
|
+
searchError = error
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
let fetched = 0
|
|
357
|
+
for (const source of sources) {
|
|
358
|
+
if (signal?.aborted || fetched >= fetchLimit) break
|
|
359
|
+
const url = officialUrl(source?.url)
|
|
360
|
+
if (!url || visited.has(url)) continue
|
|
361
|
+
visited.add(url)
|
|
362
|
+
fetched++
|
|
363
|
+
try {
|
|
364
|
+
const page = await abortable(() => web.fetch({ url }, signal), signal)
|
|
365
|
+
const finalUrl = officialUrl(page?.url || url)
|
|
366
|
+
const found = finalUrl && readExamDates(page?.body, year)
|
|
367
|
+
checked.push(url)
|
|
368
|
+
if (!found) continue
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
record: {
|
|
372
|
+
date: found.date,
|
|
373
|
+
endDate: found.endDate,
|
|
374
|
+
status: 'official',
|
|
375
|
+
source: found.title || source.title || finalUrl,
|
|
376
|
+
sourceUrl: finalUrl,
|
|
377
|
+
publishedAt: source.publishedAt || '',
|
|
378
|
+
verifiedAt: new Date().toISOString(),
|
|
379
|
+
method: 'online-search',
|
|
380
|
+
note: `由官方渠道页面核验:${finalUrl}`,
|
|
381
|
+
},
|
|
382
|
+
checked,
|
|
383
|
+
snippet: found.snippet,
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
checked.push(`${url} (${error?.message || error})`)
|
|
387
|
+
}
|
|
351
388
|
}
|
|
352
389
|
}
|
|
353
|
-
|
|
354
|
-
return {
|
|
355
|
-
|
|
356
|
-
reason: `检索到官方渠道页面,但未能在其中确认 ${expectYear}年12月的初试日期`,
|
|
357
|
-
checked,
|
|
358
|
-
}
|
|
390
|
+
if (signal?.aborted) return { ok: false, reason: '联网核验已超时或取消', checked }
|
|
391
|
+
if (!searched) return { ok: false, reason: `联网检索失败:${searchError?.message || searchError}`, checked }
|
|
392
|
+
return { ok: false, reason: `未能从官方正文确认 ${year} 考研(${expectYear}年12月)的初试起止日期`, checked }
|
|
359
393
|
}
|
|
360
394
|
|
|
361
395
|
export function addDays(iso, days) {
|
package/lib/generate.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describeSubjectForPrompt, QUESTION_TYPE_LABEL, CHOICE_TYPES } from './subjects.js'
|
|
1
|
+
import { describeSubjectForPrompt, QUESTION_TYPE_LABEL, CHOICE_TYPES, hasChoiceOptions, validChoiceKey } from './subjects.js'
|
|
2
2
|
import { asString, asStringArray, generateText, parseJsonLoose, resolveModel } from './llm-json.js'
|
|
3
3
|
import { loadExamReferences, reviewExamFit } from './references.js'
|
|
4
|
-
import { generationPlan,findQuestionRepeat } from './generation-history.js'
|
|
4
|
+
import { generationPlan,findQuestionRepeat,rankQuestionTypes } from './generation-history.js'
|
|
5
|
+
import { papersForType, typeLabel } from './paper-types.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* 在线出题 = 三个互相独立的步骤:
|
|
@@ -34,7 +35,7 @@ const WRITER_SYSTEM = `你是一位中国大陆硕士研究生招生考试的命
|
|
|
34
35
|
|
|
35
36
|
const SOLVER_SYSTEM = `你是一位独立解题专家。请求中只有题干和选项,不提供命题答案或解析。
|
|
36
37
|
请独立求解,检查题干条件、可解性和唯一性;不要猜测命题人的答案。
|
|
37
|
-
|
|
38
|
+
单选、完形和阅读在 answerKey 中只写一个正确选项字母;多选去重并升序;非选择题留空。
|
|
38
39
|
只输出 JSON:{"solvable":true,"answer":"最终答案","answerKey":"","explanation":"完整推导、依据及易错点,不少于20字","issues":[]}。
|
|
39
40
|
无法确定时 solvable 为 false,并在 issues 中说明原因。`
|
|
40
41
|
|
|
@@ -71,8 +72,23 @@ export async function createVerifiedQuestion({ ctx, subject, examInfo, signal, p
|
|
|
71
72
|
const selection = resolveModel(ctx)
|
|
72
73
|
modelIdentity = `${selection.provider}/${selection.model}`
|
|
73
74
|
} catch { /* 模型信息缺失不影响出题,校验记录里留空即可 */ }
|
|
74
|
-
const
|
|
75
|
-
const
|
|
75
|
+
const typeOrder = rankQuestionTypes(subject,history,preferredType)
|
|
76
|
+
const plannedType = typeOrder[0]
|
|
77
|
+
const loaded = await loadExamReferences({ ctx, subject, examInfo, signal, plannedType })
|
|
78
|
+
const scopeReferences = {...loaded,sources:loaded.sources.filter((source) => source.kind !== 'paper')}
|
|
79
|
+
const papers = loaded.sources.filter((source) => source.kind === 'paper')
|
|
80
|
+
let selectedType = plannedType
|
|
81
|
+
let sameTypePapers = []
|
|
82
|
+
for (const type of typeOrder) {
|
|
83
|
+
const matching = papersForType(papers,type,typeOrder)
|
|
84
|
+
if (matching.length < 2) continue
|
|
85
|
+
selectedType = type
|
|
86
|
+
sameTypePapers = matching
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
const verifyDifficulty = sameTypePapers.length >= 2
|
|
90
|
+
const references = {...scopeReferences,sources:[...scopeReferences.sources,...sameTypePapers]}
|
|
91
|
+
const plan=generationPlan({subject,references,history,preferredType:selectedType})
|
|
76
92
|
const typeHint=plan.type
|
|
77
93
|
|
|
78
94
|
for (let attempt = 1; attempt <= MAX_GENERATION_ATTEMPTS; attempt += 1) {
|
|
@@ -97,7 +113,7 @@ export async function createVerifiedQuestion({ ctx, subject, examInfo, signal, p
|
|
|
97
113
|
}
|
|
98
114
|
const candidate = normalizeCandidate(draft, allowed)
|
|
99
115
|
const problems = structuralProblems(candidate, allowed)
|
|
100
|
-
if(candidate.type!==typeHint)problems.push('
|
|
116
|
+
if(candidate.type!==typeHint)problems.push('本轮需采用指定题型:'+typeHint)
|
|
101
117
|
const repeated=findQuestionRepeat(candidate,plan.index)
|
|
102
118
|
if(repeated)problems.push(repeated+',必须换一道新题,不要仅调整措辞或参数')
|
|
103
119
|
if (rejectedStem && candidate.stem.replace(/\s+/g, '') === rejectedStem) problems.push('重新命题复用了已被拒绝的题干,必须换一道新题')
|
|
@@ -113,17 +129,41 @@ export async function createVerifiedQuestion({ ctx, subject, examInfo, signal, p
|
|
|
113
129
|
|
|
114
130
|
if (review.pass) {
|
|
115
131
|
let fit
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
let difficultyNote = verifyDifficulty ? '' : `补检后仍未取得至少两个年份的${typeLabel(typeHint)}有效真题正文。`
|
|
133
|
+
let difficultyIssues = []
|
|
134
|
+
const reviewInput = { ctx, subject, candidate: review.candidate, signal, coverageTarget:plan.topic }
|
|
135
|
+
if (verifyDifficulty) {
|
|
136
|
+
try {
|
|
137
|
+
fit = await reviewExamFit({ ...reviewInput, references })
|
|
138
|
+
if (fit.difficultyUnavailable) {
|
|
139
|
+
difficultyNote = '独立评审确认现有真题材料不足以完成难度对照。'
|
|
140
|
+
difficultyIssues = fit.issues
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
signal?.throwIfAborted()
|
|
144
|
+
difficultyNote = '独立难度评审未能返回可核对的结果。'
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (difficultyNote) {
|
|
148
|
+
try { fit = await reviewExamFit({ ...reviewInput, references:scopeReferences, verifyDifficulty:false }) }
|
|
149
|
+
catch (error) {
|
|
150
|
+
signal?.throwIfAborted()
|
|
151
|
+
fit = { passed: false, issues: ['独立大纲范围评审失败:' + error.message] }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
trail.push({ attempt, stage: 'exam-fit', passed: fit.passed, summary: fit.rationale || '独立评审未通过', issues: fit.issues })
|
|
119
155
|
if (!fit.passed) {
|
|
120
156
|
feedback = '题目答案已核验,但范围或难度不符合真实考试,请重新命题:' + fit.issues.join(';')
|
|
121
157
|
rejectedStem = candidate.stem.replace(/\s+/g, '')
|
|
122
158
|
continue
|
|
123
159
|
}
|
|
124
|
-
const
|
|
160
|
+
const evidence = difficultyNote ? scopeReferences : references
|
|
161
|
+
const examAlignment = { ...evidence,
|
|
162
|
+
status: difficultyNote ? 'difficulty-unverified' : 'passed', difficulty: fit.difficulty,
|
|
163
|
+
note: difficultyNote ? `未通过真题难度校验。${difficultyNote}已独立核对大纲范围。` : references.note,
|
|
164
|
+
rationale: fit.rationale, warnings:[...difficultyIssues,...(fit.warnings || [])],
|
|
125
165
|
outlineEvidence: fit.outlineEvidence, paperEvidence: fit.paperEvidence,
|
|
126
|
-
|
|
166
|
+
sources: evidence.sources.map(({ text, ...source }) => source) }
|
|
127
167
|
const verification = {
|
|
128
168
|
status: 'passed',
|
|
129
169
|
at: new Date().toISOString(),
|
|
@@ -133,9 +173,9 @@ export async function createVerifiedQuestion({ ctx, subject, examInfo, signal, p
|
|
|
133
173
|
attempts: attempt,
|
|
134
174
|
model: modelIdentity,
|
|
135
175
|
trail,
|
|
136
|
-
coverage:{type:typeHint,topic:plan.topic,recentWindow:30,historyCount:plan.historyCount},
|
|
176
|
+
coverage:{type:typeHint,plannedType,topic:plan.topic,recentWindow:30,historyCount:plan.historyCount},
|
|
137
177
|
}
|
|
138
|
-
return { question: { ...review.candidate, coverageTopic:plan.topic, difficulty: fit.difficulty, examAlignment }, verification }
|
|
178
|
+
return { question: { ...review.candidate, coverageTopic:plan.topic, difficulty: fit.difficulty ?? review.candidate.difficulty, examAlignment }, verification }
|
|
139
179
|
}
|
|
140
180
|
|
|
141
181
|
rejectedStem = candidate.stem.replace(/\s+/g, '')
|
|
@@ -170,7 +210,7 @@ async function writeQuestion({ ctx, subject, examInfo, allowed, allowedLabels, t
|
|
|
170
210
|
' "stem": "题干(Markdown + LaTeX)",',
|
|
171
211
|
' "options": [{"key": "A", "text": "选项内容(Markdown + LaTeX)"}], // 选择题必填,其他题型为空数组',
|
|
172
212
|
' "answer": "标准答案(Markdown)。选择题为正确选项字母,例如 B;填空题为最终结果;解答题给出最终结论",',
|
|
173
|
-
' "answerKey": "
|
|
213
|
+
' "answerKey": "单选、完形、阅读只填一个正确选项字母;多选按字母升序拼接,例如 ABD;非选择题留空",',
|
|
174
214
|
' "explanation": "完整解析(Markdown + LaTeX),必须含关键思路、推导步骤、核心知识点与易错点",',
|
|
175
215
|
' "knowledge": ["知识点标签", "..."],',
|
|
176
216
|
' "syllabusCheck": "一句话说明本题对应大纲的哪一部分"',
|
|
@@ -337,17 +377,14 @@ export function structuralProblems(candidate, allowedTypes) {
|
|
|
337
377
|
if (!candidate.explanation || candidate.explanation.length < 20) problems.push('解析缺失或过短(缺少必要推导)')
|
|
338
378
|
if (!allowedTypes.includes(candidate.type)) problems.push(`题型 ${candidate.type} 不在本科目允许范围内`)
|
|
339
379
|
if (CHOICE_TYPES.has(candidate.type)) {
|
|
340
|
-
if (candidate.options
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
problems.push('多项选择题必须写明正确选项集合')
|
|
380
|
+
if (!hasChoiceOptions(candidate.options)) problems.push('选择题至少需要两个有效选项,选项字母须为 A–H 且不可重复')
|
|
381
|
+
if (!validChoiceKey(candidate.type, candidate.answerKey, candidate.options)) {
|
|
382
|
+
problems.push(candidate.type === 'multiple'
|
|
383
|
+
? '多项选择题必须写明正确选项集合,字母不可重复且必须属于本题选项'
|
|
384
|
+
: '单选、完形和阅读必须有且仅有一个有效的正确选项')
|
|
385
|
+
} else if ([...candidate.answerKey].sort().join('') !== candidate.answerKey) {
|
|
386
|
+
problems.push('多选题正确选项必须按字母升序排列')
|
|
348
387
|
}
|
|
349
|
-
const sorted = candidate.answerKey.split('').sort()
|
|
350
|
-
if (sorted.join('') !== candidate.answerKey) problems.push('多选题正确选项必须按字母升序排列')
|
|
351
388
|
}
|
|
352
389
|
return problems
|
|
353
390
|
}
|
|
@@ -401,7 +438,7 @@ export async function reviseAnswer({ ctx, subject, examInfo, question, currentAn
|
|
|
401
438
|
'输出字段(严格 JSON):',
|
|
402
439
|
'{',
|
|
403
440
|
' "answer": "标准答案(Markdown)",',
|
|
404
|
-
' "answerKey": "
|
|
441
|
+
' "answerKey": "单选、完形、阅读只填一个正确选项字母;多选按字母升序;非选择题留空",',
|
|
405
442
|
' "explanation": "完整解析(Markdown + LaTeX)"',
|
|
406
443
|
'}',
|
|
407
444
|
].filter(Boolean).join('\n')
|
|
@@ -48,6 +48,14 @@ function outlineTopics(subject,references) {
|
|
|
48
48
|
.filter(label=>label.length>=2&&label.length<=80&&!administrative.test(label)&&!/考试大纲|^20\d{2}/.test(label)&&!typeLabels.has(label)).slice(0,40)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export function rankQuestionTypes(subject, history, preferredType) {
|
|
52
|
+
const counts = new Map(subject.types.map((type) => [type,0]))
|
|
53
|
+
for (const question of recentFirst(history).slice(0,WINDOW)) {
|
|
54
|
+
if (counts.has(question.type)) counts.set(question.type,counts.get(question.type)+1)
|
|
55
|
+
}
|
|
56
|
+
return [...subject.types].sort((a,b) => Number(b===preferredType)-Number(a===preferredType) || counts.get(a)-counts.get(b))
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
export function generationPlan({subject,references,history=[],preferredType}) {
|
|
52
60
|
const recent=recentFirst(history).slice(0,WINDOW),typeCounts=Object.fromEntries(subject.types.map(type=>[type,0])),knowledgeCounts=Object.create(null)
|
|
53
61
|
for(const q of recent) {
|