daily-question 2.0.0 → 2.1.0
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 +32 -0
- package/LICENSE +21 -21
- package/README.md +98 -20
- package/client.js +281 -61
- package/index.js +2 -2
- package/lib/async.js +17 -0
- package/lib/bank.js +3 -3
- package/lib/generate.js +121 -101
- package/lib/generation-history.js +67 -0
- package/lib/llm-json.js +17 -8
- package/lib/pdf-worker.js +5 -0
- package/lib/pdf.js +156 -34
- package/lib/reference-reader.js +178 -0
- package/lib/reference-sources.js +83 -0
- package/lib/references.js +159 -0
- package/lib/research.js +166 -0
- package/lib/routes.js +2 -0
- package/lib/service.js +199 -50
- package/lib/subjects.js +19 -0
- package/lib/tutor.js +6 -5
- package/lib/updates.js +49 -0
- package/package.json +4 -2
package/lib/service.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { findSubject, allSubjects, QUESTION_TYPE_LABEL, CHOICE_TYPES, subjectTypes } from './subjects.js'
|
|
2
|
+
import { findSubject, allSubjects, QUESTION_TYPE_LABEL, CHOICE_TYPES, subjectTypes, REFERENCE_EXAMS } from './subjects.js'
|
|
3
3
|
import { computeCountdown, resolveExamDate, todayISO, verifyExamDateOnline, yearOptions } from './exam-dates.js'
|
|
4
4
|
import { createVerifiedQuestion, QuestionGenerationError, reviseAnswer } from './generate.js'
|
|
5
5
|
import { answerFollowup } from './tutor.js'
|
|
6
6
|
import { parseBankText, extractBankPdf } from './bank.js'
|
|
7
7
|
import { Store, resolveDataDir } from './store.js'
|
|
8
|
+
import { createUpdateChecker, PLUGIN_VERSION } from './updates.js'
|
|
9
|
+
import { withTimeout } from './async.js'
|
|
10
|
+
import { indexQuestionHistory,findQuestionRepeat } from './generation-history.js'
|
|
8
11
|
|
|
9
12
|
const MAX_STEM_SUMMARY = 140
|
|
10
13
|
const IMPORT_TTL_MS = 30 * 60 * 1000
|
|
@@ -13,6 +16,12 @@ function nowISO() {
|
|
|
13
16
|
return new Date().toISOString()
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
function attemptDate(attempt) {
|
|
20
|
+
if(/^\d{4}-\d{2}-\d{2}$/.test(attempt.practiceDate||''))return attempt.practiceDate
|
|
21
|
+
const date=new Date(attempt.revealedAt||attempt.at||'')
|
|
22
|
+
return Number.isFinite(date.getTime())?todayISO(date):''
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* 自定义科目的题型:内置键(single/multiple/fill/solution…)和用户自填的文字
|
|
18
27
|
* 都允许,只做去空、去重、限长限量。
|
|
@@ -62,21 +71,65 @@ function sortLetters(value) {
|
|
|
62
71
|
* 后续可能的 Agent 工具共享同一套业务逻辑。
|
|
63
72
|
*/
|
|
64
73
|
export class DailyQuestionService {
|
|
65
|
-
constructor(ctx) {
|
|
74
|
+
constructor(ctx, { taskTimeoutMs } = {}) {
|
|
66
75
|
this.ctx = ctx
|
|
67
76
|
this.store = new Store(resolveDataDir())
|
|
68
77
|
this.inflightDaily = new Map()
|
|
78
|
+
this.inflightFollowups = new Map()
|
|
79
|
+
this.recovery = null
|
|
80
|
+
this.attemptIndexes = new WeakMap()
|
|
81
|
+
this.tasks = new Map()
|
|
82
|
+
this.taskTimeoutMs = taskTimeoutMs
|
|
69
83
|
this.imports = new Map()
|
|
84
|
+
this.checkUpdates = createUpdateChecker()
|
|
70
85
|
this.log = typeof ctx.logger === 'function' ? ctx.logger('daily-question') : (ctx.logger || console)
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
async init() {
|
|
74
|
-
await this.
|
|
89
|
+
await this.data()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
runTask(key, details, run) {
|
|
93
|
+
if (this.tasks.has(key)) return this.tasks.get(key).promise
|
|
94
|
+
const job = { ...details, controller: new AbortController() }
|
|
95
|
+
job.promise = withTimeout(run, { signal: job.controller.signal,
|
|
96
|
+
timeoutMs: this.taskTimeoutMs ?? (details.kind === 'daily' ? 15*60*1000 : details.kind === 'revise' ? 10*60*1000 : 5*60*1000),
|
|
97
|
+
message: '任务超时,已停止本次生成,可以重试',
|
|
98
|
+
}).finally(() => { if (this.tasks.get(key) === job) this.tasks.delete(key) })
|
|
99
|
+
this.tasks.set(key, job)
|
|
100
|
+
return job.promise
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async cancelTask({ kind, subjectId, qid, clientMessageId }) {
|
|
104
|
+
if (!['daily','revise','followup'].includes(kind)) throw new Error('不支持取消此类任务')
|
|
105
|
+
const jobs = [...this.tasks.values()].filter((job) => job.kind === kind && (kind === 'daily'
|
|
106
|
+
? Boolean(subjectId) && job.subjectId === subjectId
|
|
107
|
+
: Boolean(qid) && job.qid === qid && (kind !== 'followup' || !clientMessageId || job.clientMessageId === clientMessageId)))
|
|
108
|
+
for (const job of jobs) job.controller.abort(Object.assign(new Error('任务已取消,可以重试'), { code:'TASK_CANCELLED' }))
|
|
109
|
+
await Promise.allSettled(jobs.map((job) => job.promise))
|
|
110
|
+
return { ok: true, cancelled: jobs.length }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
dispose() {
|
|
114
|
+
for (const job of this.tasks.values()) job.controller.abort(new Error('插件已停止,本次任务已取消'))
|
|
75
115
|
}
|
|
76
116
|
|
|
77
117
|
/* ------------------------------ 读取与状态 ------------------------------ */
|
|
78
118
|
|
|
79
119
|
async data() {
|
|
120
|
+
if (!this.recovery) {
|
|
121
|
+
this.recovery = (async () => {
|
|
122
|
+
const data = await this.store.read()
|
|
123
|
+
if (Object.values(data.threads).some((thread) => thread.messages?.some((message) => message.status === 'pending'))) {
|
|
124
|
+
await this.store.update((draft) => {
|
|
125
|
+
for (const thread of Object.values(draft.threads)) {
|
|
126
|
+
for (const message of thread.messages || []) if (message.status === 'pending') message.status = 'failed'
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
})().catch((error) => { this.recovery = null; throw error })
|
|
131
|
+
}
|
|
132
|
+
await this.recovery
|
|
80
133
|
return this.store.read()
|
|
81
134
|
}
|
|
82
135
|
|
|
@@ -89,7 +142,9 @@ export class DailyQuestionService {
|
|
|
89
142
|
}
|
|
90
143
|
|
|
91
144
|
async bootstrap() {
|
|
92
|
-
const
|
|
145
|
+
const now = new Date()
|
|
146
|
+
const today = todayISO(now)
|
|
147
|
+
const nextDayAt = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString()
|
|
93
148
|
let data = await this.data()
|
|
94
149
|
const options = yearOptions(today, data.examDates)
|
|
95
150
|
// 旧设置可能落在窗口外(它的初试早已结束):自动收敛到可选的最早一年
|
|
@@ -112,12 +167,15 @@ export class DailyQuestionService {
|
|
|
112
167
|
return {
|
|
113
168
|
ok: true,
|
|
114
169
|
dataFile: this.store.file,
|
|
170
|
+
pluginVersion: PLUGIN_VERSION,
|
|
115
171
|
dataError: this.store.lastError,
|
|
116
172
|
today,
|
|
117
|
-
now:
|
|
173
|
+
now: now.toISOString(),
|
|
174
|
+
nextDayAt,
|
|
118
175
|
yearOptions: options,
|
|
119
176
|
settings: data.settings,
|
|
120
177
|
customSubjects: data.customSubjects,
|
|
178
|
+
referenceExams: REFERENCE_EXAMS,
|
|
121
179
|
examInfo,
|
|
122
180
|
countdown,
|
|
123
181
|
subjects: this.subjectViews(data),
|
|
@@ -140,6 +198,9 @@ export class DailyQuestionService {
|
|
|
140
198
|
types: subject.types,
|
|
141
199
|
typeLabels: subject.types.map((type) => QUESTION_TYPE_LABEL[type] || type),
|
|
142
200
|
custom: Boolean(subject.custom),
|
|
201
|
+
referenceSubjectId: subject.referenceSubjectId || '',
|
|
202
|
+
referenceExamName: subject.referenceExamName || '',
|
|
203
|
+
referenceExamCode: subject.referenceExamCode || '',
|
|
143
204
|
enabled: enabled.has(subject.id),
|
|
144
205
|
source: data.settings.subjectSource[subject.id] || 'auto',
|
|
145
206
|
bankItemCount: usableBanks.reduce((total, bank) => total
|
|
@@ -164,15 +225,14 @@ export class DailyQuestionService {
|
|
|
164
225
|
|
|
165
226
|
counts(data) {
|
|
166
227
|
const result = {}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
result[subjectId]
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
228
|
+
const enabled=new Set(data.settings.subjects)
|
|
229
|
+
for(const id of enabled)result[id]={all:0,favorite:0,wrong:0}
|
|
230
|
+
for(const question of Object.values(data.questions)) {
|
|
231
|
+
if(!enabled.has(question.subjectId))continue
|
|
232
|
+
const count=result[question.subjectId]
|
|
233
|
+
count.all++
|
|
234
|
+
if(question.favoriteAt)count.favorite++
|
|
235
|
+
if(this.isWrong(question,this.attemptsOf(data,question.id)))count.wrong++
|
|
176
236
|
}
|
|
177
237
|
return result
|
|
178
238
|
}
|
|
@@ -180,12 +240,21 @@ export class DailyQuestionService {
|
|
|
180
240
|
isWrong(question, attempts) {
|
|
181
241
|
if (question.wrongBookRemoved) return false
|
|
182
242
|
if (question.wrongBookAdded) return true
|
|
183
|
-
|
|
184
|
-
return own.some((attempt) => attempt.result === 'wrong')
|
|
243
|
+
return attempts.some((attempt) => attempt.questionId === question.id && attempt.result === 'wrong')
|
|
185
244
|
}
|
|
186
245
|
|
|
187
246
|
attemptsOf(data, qid) {
|
|
188
|
-
|
|
247
|
+
let index=this.attemptIndexes.get(data)
|
|
248
|
+
if(!index||index.source!==data.attempts||index.length!==data.attempts.length) {
|
|
249
|
+
const byQuestion=new Map()
|
|
250
|
+
for(const attempt of data.attempts) {
|
|
251
|
+
if(!byQuestion.has(attempt.questionId))byQuestion.set(attempt.questionId,[])
|
|
252
|
+
byQuestion.get(attempt.questionId).push(attempt)
|
|
253
|
+
}
|
|
254
|
+
index={source:data.attempts,length:data.attempts.length,byQuestion}
|
|
255
|
+
this.attemptIndexes.set(data,index)
|
|
256
|
+
}
|
|
257
|
+
return index.byQuestion.get(qid)||[]
|
|
189
258
|
}
|
|
190
259
|
|
|
191
260
|
/* ------------------------------ 设置 ------------------------------ */
|
|
@@ -194,6 +263,9 @@ export class DailyQuestionService {
|
|
|
194
263
|
const data = await this.store.update((draft) => {
|
|
195
264
|
// 先落自定义科目,后面的 enabled 校验才能识别到它们。
|
|
196
265
|
if (Array.isArray(patch.customSubjects)) {
|
|
266
|
+
if (patch.customSubjects.some((item) => item?.referenceSubjectId === 'independent' && !(typeof item.referenceExamName === 'string' && item.referenceExamName.trim()))) {
|
|
267
|
+
throw new Error('独立参考考试请填写准确的考试名称(院校自命题请包含院校名称)')
|
|
268
|
+
}
|
|
197
269
|
draft.customSubjects = patch.customSubjects
|
|
198
270
|
.filter((item) => item && typeof item.id === 'string' && typeof item.name === 'string')
|
|
199
271
|
.map((item) => ({
|
|
@@ -203,6 +275,9 @@ export class DailyQuestionService {
|
|
|
203
275
|
types: normalizeCustomTypes(item.types),
|
|
204
276
|
outline: Array.isArray(item.outline) ? item.outline.filter((line) => typeof line === 'string').slice(0, 20) : [],
|
|
205
277
|
guidance: typeof item.guidance === 'string' ? item.guidance : '',
|
|
278
|
+
referenceSubjectId: ['independent','auto'].includes(item.referenceSubjectId) ? item.referenceSubjectId : REFERENCE_EXAMS.find((exam) => exam.id === item.referenceSubjectId)?.id || '',
|
|
279
|
+
referenceExamName: typeof item.referenceExamName === 'string' ? item.referenceExamName.trim().slice(0,100) : '',
|
|
280
|
+
referenceExamCode: typeof item.referenceExamCode === 'string' ? item.referenceExamCode.trim().slice(0,20) : '',
|
|
206
281
|
}))
|
|
207
282
|
}
|
|
208
283
|
if (patch.targetYear !== undefined) {
|
|
@@ -269,14 +344,15 @@ export class DailyQuestionService {
|
|
|
269
344
|
return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
|
|
270
345
|
}
|
|
271
346
|
|
|
272
|
-
const task = this.
|
|
347
|
+
const task = this.runTask('daily:'+key, {kind:'daily',subjectId:subject.id},
|
|
348
|
+
(signal) => this.createDaily(subject, date, key, signal)).finally(() => this.inflightDaily.delete(key))
|
|
273
349
|
this.inflightDaily.set(key, task)
|
|
274
350
|
const result = await task
|
|
275
351
|
const fresh = await this.data()
|
|
276
352
|
return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
|
|
277
353
|
}
|
|
278
354
|
|
|
279
|
-
async createDaily(subject, date, key) {
|
|
355
|
+
async createDaily(subject, date, key, signal) {
|
|
280
356
|
const data = await this.data()
|
|
281
357
|
const source = data.settings.subjectSource[subject.id] || 'auto'
|
|
282
358
|
const examInfo = resolveExamDate(data.settings.targetYear, data.examDates)
|
|
@@ -284,7 +360,7 @@ export class DailyQuestionService {
|
|
|
284
360
|
try {
|
|
285
361
|
if (source !== 'online') {
|
|
286
362
|
const picked = this.pickFromBank(data, subject, date)
|
|
287
|
-
if (picked) return await this.publishBankQuestion(picked, subject, date, key)
|
|
363
|
+
if (picked) return await this.publishBankQuestion(picked, subject, date, key, signal)
|
|
288
364
|
if (source === 'bank') {
|
|
289
365
|
throw new Error('题库中没有符合该科目要求的可用题目:请先导入题库,或把该科目的出题来源切换为在线出题')
|
|
290
366
|
}
|
|
@@ -294,6 +370,8 @@ export class DailyQuestionService {
|
|
|
294
370
|
ctx: this.ctx,
|
|
295
371
|
subject,
|
|
296
372
|
examInfo,
|
|
373
|
+
signal,
|
|
374
|
+
history:Object.values(data.questions).filter(question=>question.subjectId===subject.id),
|
|
297
375
|
})
|
|
298
376
|
const qid = `q_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`
|
|
299
377
|
const stamp = nowISO()
|
|
@@ -305,7 +383,9 @@ export class DailyQuestionService {
|
|
|
305
383
|
stem: generated.stem,
|
|
306
384
|
options: generated.options,
|
|
307
385
|
knowledge: generated.knowledge,
|
|
386
|
+
coverageTopic:generated.coverageTopic,
|
|
308
387
|
difficulty: generated.difficulty,
|
|
388
|
+
examAlignment: generated.examAlignment,
|
|
309
389
|
syllabusCheck: generated.syllabusCheck || '',
|
|
310
390
|
source: {
|
|
311
391
|
kind: 'online',
|
|
@@ -338,6 +418,9 @@ export class DailyQuestionService {
|
|
|
338
418
|
}
|
|
339
419
|
|
|
340
420
|
await this.store.update((draft) => {
|
|
421
|
+
signal?.throwIfAborted()
|
|
422
|
+
const history=Object.values(draft.questions).filter(question=>question.subjectId===subject.id)
|
|
423
|
+
if(findQuestionRepeat(generated,indexQuestionHistory(history)))throw new Error('题目与已发布题目重复,已阻止发布,请重试生成新题')
|
|
341
424
|
draft.questions[qid] = record
|
|
342
425
|
draft.daily[key] = { date, subjectId: subject.id, questionId: qid, status: 'ready', createdAt: stamp }
|
|
343
426
|
delete draft.failures[subject.id]
|
|
@@ -345,6 +428,7 @@ export class DailyQuestionService {
|
|
|
345
428
|
})
|
|
346
429
|
return { questionId: qid, created: true }
|
|
347
430
|
} catch (error) {
|
|
431
|
+
if (signal?.aborted) throw signal.reason
|
|
348
432
|
const message = error instanceof QuestionGenerationError
|
|
349
433
|
? error.message
|
|
350
434
|
: `在线出题失败:${error?.message || error}`
|
|
@@ -378,10 +462,11 @@ export class DailyQuestionService {
|
|
|
378
462
|
return { ...chosen, repeated }
|
|
379
463
|
}
|
|
380
464
|
|
|
381
|
-
async publishBankQuestion(picked, subject, date, key) {
|
|
465
|
+
async publishBankQuestion(picked, subject, date, key, signal) {
|
|
382
466
|
const { bank, item, qid, repeated } = picked
|
|
383
467
|
const stamp = nowISO()
|
|
384
468
|
await this.store.update((draft) => {
|
|
469
|
+
signal?.throwIfAborted()
|
|
385
470
|
const existing = draft.questions[qid]
|
|
386
471
|
if (existing) {
|
|
387
472
|
existing.assignedCount = (existing.assignedCount || 1) + 1
|
|
@@ -454,10 +539,12 @@ export class DailyQuestionService {
|
|
|
454
539
|
|
|
455
540
|
/* ------------------------------ 题目视图 ------------------------------ */
|
|
456
541
|
|
|
457
|
-
questionView(data, question) {
|
|
542
|
+
questionView(data, question, { attemptId, date = todayISO() } = {}) {
|
|
458
543
|
const attempts = this.attemptsOf(data, question.id)
|
|
459
|
-
const
|
|
460
|
-
const
|
|
544
|
+
const isToday=data.daily[`${date}|${question.subjectId}`]?.questionId===question.id
|
|
545
|
+
const currentAttempts=isToday?attempts.filter(attempt=>attemptDate(attempt)===date):attempts
|
|
546
|
+
const revealed = currentAttempts.length > 0
|
|
547
|
+
const last = (attemptId ? currentAttempts.find((attempt) => attempt.id === attemptId) : currentAttempts[currentAttempts.length - 1]) || null
|
|
461
548
|
const answer = question.currentAnswer
|
|
462
549
|
return {
|
|
463
550
|
id: question.id,
|
|
@@ -471,8 +558,17 @@ export class DailyQuestionService {
|
|
|
471
558
|
options: question.options || [],
|
|
472
559
|
knowledge: question.knowledge || [],
|
|
473
560
|
difficulty: question.difficulty || 3,
|
|
561
|
+
examAlignment: question.examAlignment ? {
|
|
562
|
+
...question.examAlignment,
|
|
563
|
+
rationale: revealed ? question.examAlignment.rationale : '',
|
|
564
|
+
warnings: revealed ? question.examAlignment.warnings || [] : [],
|
|
565
|
+
outlineEvidence: revealed ? question.examAlignment.outlineEvidence : [],
|
|
566
|
+
paperEvidence: revealed ? question.examAlignment.paperEvidence : [],
|
|
567
|
+
} : null,
|
|
474
568
|
source: question.source,
|
|
475
|
-
assignedDate: question.assignedDate,
|
|
569
|
+
assignedDate: isToday?date:question.assignedDate,
|
|
570
|
+
practiceDate:isToday?date:null,
|
|
571
|
+
todayAttemptCount:isToday?currentAttempts.length:0,
|
|
476
572
|
createdAt: question.createdAt,
|
|
477
573
|
assignedCount: question.assignedCount || 1,
|
|
478
574
|
repeated: Boolean(question.repeated),
|
|
@@ -485,6 +581,7 @@ export class DailyQuestionService {
|
|
|
485
581
|
mode: attempt.mode,
|
|
486
582
|
choice: attempt.choice || '',
|
|
487
583
|
judgedAt: attempt.judgedAt || null,
|
|
584
|
+
practiceDate:attemptDate(attempt),
|
|
488
585
|
})),
|
|
489
586
|
attemptCount: attempts.length,
|
|
490
587
|
lastAttempt: last ? { id: last.id, at: last.at, result: last.result, mode: last.mode, choice: last.choice || '' } : null,
|
|
@@ -552,9 +649,11 @@ export class DailyQuestionService {
|
|
|
552
649
|
}
|
|
553
650
|
}
|
|
554
651
|
|
|
555
|
-
async listQuestions({ subjectId, kind = 'all' } = {}) {
|
|
652
|
+
async listQuestions({ subjectId, kind = 'all', page = 1, pageSize = 50, idsOnly = false } = {}) {
|
|
556
653
|
const data = await this.data()
|
|
557
654
|
if (!subjectId) throw new Error('缺少科目')
|
|
655
|
+
if(!['all','favorite','wrong','today'].includes(kind))throw new Error('未知题目栏目')
|
|
656
|
+
if(!Number.isInteger(page)||page<1||!Number.isInteger(pageSize)||pageSize<1||pageSize>100)throw new Error('分页参数无效,每页最多100道题')
|
|
558
657
|
let questions = Object.values(data.questions).filter((question) => question.subjectId === subjectId)
|
|
559
658
|
if (kind === 'favorite') questions = questions.filter((question) => question.favoriteAt)
|
|
560
659
|
if (kind === 'wrong') {
|
|
@@ -568,12 +667,16 @@ export class DailyQuestionService {
|
|
|
568
667
|
questions = questions.filter((question) => question.id === record?.questionId)
|
|
569
668
|
}
|
|
570
669
|
questions.sort((a, b) => String(b.assignedDate).localeCompare(String(a.assignedDate))
|
|
571
|
-
|| String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
670
|
+
|| String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id)))
|
|
671
|
+
const total=questions.length,totalPages=Math.max(1,Math.ceil(total/pageSize))
|
|
672
|
+
if(idsOnly===true)return {ok:true,kind,subjectId,total,ids:questions.map(q=>q.id)}
|
|
673
|
+
page=Math.min(page,totalPages)
|
|
572
674
|
return {
|
|
573
675
|
ok: true,
|
|
574
676
|
kind,
|
|
575
677
|
subjectId,
|
|
576
|
-
|
|
678
|
+
total,page,pageSize,totalPages,
|
|
679
|
+
items: questions.slice((page-1)*pageSize,page*pageSize).map((question) => this.summaryView(data, question)),
|
|
577
680
|
}
|
|
578
681
|
}
|
|
579
682
|
|
|
@@ -582,8 +685,9 @@ export class DailyQuestionService {
|
|
|
582
685
|
const question = data.questions[qid]
|
|
583
686
|
if (!question) throw new Error('题目不存在(可能已随题库删除而失效)')
|
|
584
687
|
const thread = this.threadView(data, qid)
|
|
585
|
-
const
|
|
586
|
-
|
|
688
|
+
const date=todayISO()
|
|
689
|
+
const isToday = data.daily[`${date}|${question.subjectId}`]?.questionId === qid
|
|
690
|
+
return { ok: true, question: this.questionView(data, question, {date}), thread, isToday }
|
|
587
691
|
}
|
|
588
692
|
|
|
589
693
|
threadView(data, qid) {
|
|
@@ -609,7 +713,9 @@ export class DailyQuestionService {
|
|
|
609
713
|
if (!isChoice) throw new Error('主观题不支持提交选项')
|
|
610
714
|
normalizedChoice = normalizeChoice(choice, question)
|
|
611
715
|
const key = sortLetters(question.currentAnswer?.answerKey)
|
|
612
|
-
|
|
716
|
+
const canGrade = key && key.split('').every((letter) => question.options?.some((option) => option.key === letter))
|
|
717
|
+
&& (question.type !== 'single' || key.length === 1)
|
|
718
|
+
result = canGrade ? (normalizedChoice === key ? 'correct' : 'wrong') : 'pending'
|
|
613
719
|
} else if (mode === 'giveup') {
|
|
614
720
|
result = 'wrong'
|
|
615
721
|
} else if (mode === 'done') {
|
|
@@ -623,12 +729,13 @@ export class DailyQuestionService {
|
|
|
623
729
|
id: attemptId || randomUUID(),
|
|
624
730
|
questionId: qid,
|
|
625
731
|
at: stamp,
|
|
732
|
+
practiceDate:todayISO(new Date(stamp)),
|
|
626
733
|
type: question.type,
|
|
627
734
|
mode,
|
|
628
735
|
choice: normalizedChoice,
|
|
629
736
|
result,
|
|
630
737
|
revealedAt: stamp,
|
|
631
|
-
judgedAt:
|
|
738
|
+
judgedAt: result === 'pending' ? null : stamp,
|
|
632
739
|
}
|
|
633
740
|
|
|
634
741
|
const duplicated = await this.store.update((draft) => {
|
|
@@ -647,23 +754,28 @@ export class DailyQuestionService {
|
|
|
647
754
|
return false
|
|
648
755
|
})
|
|
649
756
|
const fresh = await this.data()
|
|
650
|
-
return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question), duplicated }
|
|
757
|
+
return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question, {attemptId:attempt.id}), duplicated }
|
|
651
758
|
}
|
|
652
759
|
|
|
653
|
-
async judge({ qid, verdict }) {
|
|
760
|
+
async judge({ qid, verdict, attemptId }) {
|
|
654
761
|
if (!['correct', 'wrong'].includes(verdict)) throw new Error('判定结果不合法')
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
762
|
+
if (typeof attemptId !== 'string' || !attemptId) throw new Error('缺少本次作答ID,请重新打开题目后判定')
|
|
763
|
+
const duplicated=await this.store.update((draft) => {
|
|
764
|
+
if (!draft.questions[qid]) throw new Error('题目不存在')
|
|
765
|
+
const target = draft.attempts.find((attempt) => attempt.id === attemptId && attempt.questionId === qid)
|
|
766
|
+
if (!target) throw new Error('本次作答不存在或不属于这道题')
|
|
767
|
+
if (target.result !== 'pending' || target.judgedAt) {
|
|
768
|
+
if (target.result === verdict && target.judgedAt) return true
|
|
769
|
+
throw new Error('本次作答已判定,不能覆盖或改判其他作答')
|
|
770
|
+
}
|
|
659
771
|
target.result = verdict
|
|
660
772
|
target.judgedAt = nowISO()
|
|
661
773
|
const question = draft.questions[qid]
|
|
662
774
|
if (question) question.updatedAt = nowISO()
|
|
663
|
-
return
|
|
775
|
+
return false
|
|
664
776
|
})
|
|
665
777
|
const data = await this.data()
|
|
666
|
-
return { ok: true, question: this.questionView(data, data.questions[qid]) }
|
|
778
|
+
return { ok: true, question: this.questionView(data, data.questions[qid], {attemptId}), duplicated }
|
|
667
779
|
}
|
|
668
780
|
|
|
669
781
|
async favorite({ qid, value }) {
|
|
@@ -701,7 +813,7 @@ export class DailyQuestionService {
|
|
|
701
813
|
if (!Object.hasOwn(draft.questions, id)) throw new Error('部分题目已不存在,请刷新列表后重试')
|
|
702
814
|
return draft.questions[id]
|
|
703
815
|
})
|
|
704
|
-
const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, draft.
|
|
816
|
+
const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, this.attemptsOf(draft,q.id))).length
|
|
705
817
|
if (action === 'delete' && protectedCount && confirmProtected !== true) {
|
|
706
818
|
return { requiresConfirmation: true, protectedCount }
|
|
707
819
|
}
|
|
@@ -734,6 +846,10 @@ export class DailyQuestionService {
|
|
|
734
846
|
/* ------------------------------ 答案修订 ------------------------------ */
|
|
735
847
|
|
|
736
848
|
async revise({ qid, reason = '' }) {
|
|
849
|
+
return this.runTask('revise:'+qid, {kind:'revise',qid}, (signal) => this.createRevision({qid,reason}, signal))
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async createRevision({ qid, reason }, signal) {
|
|
737
853
|
const data = await this.data()
|
|
738
854
|
const question = data.questions[qid]
|
|
739
855
|
if (!question) throw new Error('题目不存在')
|
|
@@ -746,6 +862,7 @@ export class DailyQuestionService {
|
|
|
746
862
|
question,
|
|
747
863
|
currentAnswer: question.currentAnswer,
|
|
748
864
|
reason,
|
|
865
|
+
signal,
|
|
749
866
|
})
|
|
750
867
|
const candidate = {
|
|
751
868
|
candidateId: `c_${randomUUID().slice(0, 8)}`,
|
|
@@ -758,6 +875,7 @@ export class DailyQuestionService {
|
|
|
758
875
|
}
|
|
759
876
|
await this.store.update((draft) => {
|
|
760
877
|
const target = draft.questions[qid]
|
|
878
|
+
signal.throwIfAborted()
|
|
761
879
|
if (!target) throw new Error('题目不存在')
|
|
762
880
|
target.pendingRevision = candidate
|
|
763
881
|
return draft
|
|
@@ -820,6 +938,24 @@ export class DailyQuestionService {
|
|
|
820
938
|
/* ------------------------------ 追问 ------------------------------ */
|
|
821
939
|
|
|
822
940
|
async followup({ qid, message, clientMessageId }) {
|
|
941
|
+
const messageId = clientMessageId || randomUUID()
|
|
942
|
+
const key = JSON.stringify([qid, messageId])
|
|
943
|
+
if (this.inflightFollowups.has(key)) return this.inflightFollowups.get(key)
|
|
944
|
+
const executionId = randomUUID()
|
|
945
|
+
const task = this.runTask('followup:'+key, {kind:'followup',qid,clientMessageId:messageId},
|
|
946
|
+
(signal) => this.createFollowup({ qid, message, clientMessageId: messageId, executionId }, signal))
|
|
947
|
+
.catch(async (error) => {
|
|
948
|
+
await this.store.update((draft) => {
|
|
949
|
+
const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
|
|
950
|
+
if (target?.executionId === executionId && target.status === 'pending') target.status = 'failed'
|
|
951
|
+
}).catch(() => {})
|
|
952
|
+
throw error
|
|
953
|
+
}).finally(() => this.inflightFollowups.delete(key))
|
|
954
|
+
this.inflightFollowups.set(key, task)
|
|
955
|
+
return task
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async createFollowup({ qid, message, clientMessageId, executionId }, signal) {
|
|
823
959
|
const text = String(message || '').trim()
|
|
824
960
|
if (!text) throw new Error('追问内容不能为空')
|
|
825
961
|
if (text.length > 4000) throw new Error('追问内容过长,请精简后再发送')
|
|
@@ -838,16 +974,20 @@ export class DailyQuestionService {
|
|
|
838
974
|
const messageId = failed ? failed.id : (clientMessageId || randomUUID())
|
|
839
975
|
|
|
840
976
|
// 先落盘用户消息:即使回复失败,用户输入也不会丢,并且可以按同一 id 重试。
|
|
841
|
-
await this.store.update((draft) => {
|
|
977
|
+
const queued = await this.store.update((draft) => {
|
|
978
|
+
signal.throwIfAborted()
|
|
842
979
|
if (!draft.questions[qid]) throw new Error('题目已删除')
|
|
843
980
|
if (!draft.threads[qid]) {
|
|
844
|
-
draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: stamp }
|
|
981
|
+
draft.threads[qid] = { threadId: qid, questionId: qid, generation: randomUUID(), messages: [], updatedAt: stamp }
|
|
845
982
|
}
|
|
846
983
|
const thread = draft.threads[qid]
|
|
984
|
+
if (!thread.generation) thread.generation = randomUUID()
|
|
847
985
|
const target = thread.messages.find((item) => item.id === messageId)
|
|
848
986
|
if (target) {
|
|
987
|
+
if (target.status !== 'failed') return { duplicated: true, generation: thread.generation }
|
|
849
988
|
target.text = text
|
|
850
989
|
target.status = 'pending'
|
|
990
|
+
target.executionId = executionId
|
|
851
991
|
} else {
|
|
852
992
|
thread.messages.push({
|
|
853
993
|
id: messageId,
|
|
@@ -857,17 +997,19 @@ export class DailyQuestionService {
|
|
|
857
997
|
text,
|
|
858
998
|
createdAt: stamp,
|
|
859
999
|
status: 'pending',
|
|
1000
|
+
executionId,
|
|
860
1001
|
})
|
|
861
1002
|
}
|
|
862
1003
|
thread.updatedAt = stamp
|
|
863
|
-
return
|
|
1004
|
+
return { duplicated: false, generation: thread.generation }
|
|
864
1005
|
})
|
|
1006
|
+
if (queued.duplicated) return { ok: true, thread: this.threadView(await this.data(), qid), duplicated: true }
|
|
865
1007
|
|
|
866
1008
|
const history = (await this.data()).threads[qid]?.messages || []
|
|
867
1009
|
const setStatus = async (status) => {
|
|
868
1010
|
await this.store.update((draft) => {
|
|
869
1011
|
const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
|
|
870
|
-
if (target) target.status = status
|
|
1012
|
+
if (target?.executionId === executionId) target.status = status
|
|
871
1013
|
return draft
|
|
872
1014
|
}).catch(() => {})
|
|
873
1015
|
}
|
|
@@ -879,6 +1021,7 @@ export class DailyQuestionService {
|
|
|
879
1021
|
answerDetail: question.currentAnswer,
|
|
880
1022
|
history: history.filter((item) => item.id !== messageId),
|
|
881
1023
|
message: text,
|
|
1024
|
+
signal,
|
|
882
1025
|
})
|
|
883
1026
|
const assistantMessage = {
|
|
884
1027
|
id: randomUUID(),
|
|
@@ -890,11 +1033,12 @@ export class DailyQuestionService {
|
|
|
890
1033
|
status: 'sent',
|
|
891
1034
|
}
|
|
892
1035
|
await this.store.update((draft) => {
|
|
1036
|
+
signal.throwIfAborted()
|
|
893
1037
|
if (!draft.questions[qid]) throw new Error('题目已删除')
|
|
894
|
-
if (!draft.threads[qid]) draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: nowISO() }
|
|
895
1038
|
const thread = draft.threads[qid]
|
|
896
|
-
const target = thread
|
|
897
|
-
if (target
|
|
1039
|
+
const target = thread?.messages.find((item) => item.id === messageId)
|
|
1040
|
+
if (!thread || thread.generation !== queued.generation || !target || target.executionId !== executionId || target.status !== 'pending') throw new Error('本次追问已清空或失效,已丢弃迟到的回复')
|
|
1041
|
+
target.status = 'sent'
|
|
898
1042
|
thread.messages.push(assistantMessage)
|
|
899
1043
|
thread.updatedAt = assistantMessage.createdAt
|
|
900
1044
|
return draft
|
|
@@ -909,9 +1053,14 @@ export class DailyQuestionService {
|
|
|
909
1053
|
|
|
910
1054
|
async clearThread({ qid }) {
|
|
911
1055
|
await this.store.update((draft) => {
|
|
912
|
-
if (draft.threads[qid])
|
|
1056
|
+
if (draft.threads[qid]) {
|
|
1057
|
+
draft.threads[qid].messages = []
|
|
1058
|
+
draft.threads[qid].generation = randomUUID()
|
|
1059
|
+
draft.threads[qid].updatedAt = nowISO()
|
|
1060
|
+
}
|
|
913
1061
|
return draft
|
|
914
1062
|
})
|
|
1063
|
+
await this.cancelTask({kind:'followup',qid})
|
|
915
1064
|
const data = await this.data()
|
|
916
1065
|
return { ok: true, thread: this.threadView(data, qid) }
|
|
917
1066
|
}
|
|
@@ -925,7 +1074,7 @@ export class DailyQuestionService {
|
|
|
925
1074
|
if (!base64) throw new Error('缺少 PDF 文件内容')
|
|
926
1075
|
const bytes = Buffer.from(String(base64), 'base64')
|
|
927
1076
|
if (bytes.length > 40 * 1024 * 1024) throw new Error('PDF 文件过大(上限 40MB)')
|
|
928
|
-
const extracted = extractBankPdf(bytes, fileName || name || '')
|
|
1077
|
+
const extracted = await extractBankPdf(bytes, fileName || name || '')
|
|
929
1078
|
content = extracted.text
|
|
930
1079
|
warnings = extracted.warnings
|
|
931
1080
|
} else {
|
package/lib/subjects.js
CHANGED
|
@@ -100,6 +100,22 @@ export const SUBJECT_CATALOG = [
|
|
|
100
100
|
|
|
101
101
|
const CATALOG_BY_ID = new Map(SUBJECT_CATALOG.map((item) => [item.id, item]))
|
|
102
102
|
|
|
103
|
+
/** 参考考试独立于学习科目;不会因此在每日科目列表中新增408。 */
|
|
104
|
+
export const REFERENCE_EXAMS = [
|
|
105
|
+
...SUBJECT_CATALOG.map(({id,name,code}) => ({id,name,code})),
|
|
106
|
+
{id:'cs408',name:'计算机学科专业基础',code:'408'},
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
export function referenceExamFor(subject) {
|
|
110
|
+
if (!subject?.custom) return subject
|
|
111
|
+
if (!subject.referenceSubjectId || subject.referenceSubjectId === 'auto') return {id:'auto_'+subject.id,name:subject.name,code:subject.code || ''}
|
|
112
|
+
if (subject.referenceSubjectId === 'independent') {
|
|
113
|
+
const name = typeof subject.referenceExamName === 'string' ? subject.referenceExamName.trim() : ''
|
|
114
|
+
return name ? {id:'independent_'+subject.id,name,code:subject.referenceExamCode || ''} : undefined
|
|
115
|
+
}
|
|
116
|
+
return REFERENCE_EXAMS.find((exam) => exam.id === subject.referenceSubjectId)
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
/** 全部可选科目 = 内置目录 + 用户自定义。 */
|
|
104
120
|
export function allSubjects(customSubjects = []) {
|
|
105
121
|
const extra = Array.isArray(customSubjects)
|
|
@@ -113,6 +129,9 @@ export function allSubjects(customSubjects = []) {
|
|
|
113
129
|
outline: Array.isArray(item.outline) ? item.outline : [],
|
|
114
130
|
guidance: typeof item.guidance === 'string' ? item.guidance : '',
|
|
115
131
|
custom: true,
|
|
132
|
+
referenceSubjectId: typeof item.referenceSubjectId === 'string' ? item.referenceSubjectId : '',
|
|
133
|
+
referenceExamName: typeof item.referenceExamName === 'string' ? item.referenceExamName : '',
|
|
134
|
+
referenceExamCode: typeof item.referenceExamCode === 'string' ? item.referenceExamCode : '',
|
|
116
135
|
}))
|
|
117
136
|
: []
|
|
118
137
|
return [...SUBJECT_CATALOG, ...extra]
|
package/lib/tutor.js
CHANGED
|
@@ -7,7 +7,7 @@ const TUTOR_SYSTEM = `你是中国大陆硕士研究生招生考试的辅导老
|
|
|
7
7
|
- 只讨论当前这道题:题干、选项、标准答案、解析以及由它们直接延伸的知识点。如果学生的提问明显超出这道题的范围,先说明它与本题的关系,再简短回答,不要把它当成跨题的通用聊天。
|
|
8
8
|
- 用 Markdown 回答;数学公式用 LaTeX(行内 $...$,独立公式 $$...$$)。
|
|
9
9
|
- 解释要一步步来,指出关键依据,不要只给结论。
|
|
10
|
-
-
|
|
10
|
+
- 你给出的解释是对当前题目参考答案的补充说明,不能宣称修改了已保存的答案与解析;模型生成、导入或修订的答案都不能称为“官方答案”。
|
|
11
11
|
- 如果题目资料里标记答案尚未核验或缺少参考答案,必须如实说明,不要把未核验的内容说成确定结论。
|
|
12
12
|
- 不要编造与题目无关的事实。`
|
|
13
13
|
|
|
@@ -29,12 +29,13 @@ export function describeQuestionContext(question, answerDetail) {
|
|
|
29
29
|
for (const option of question.options) lines.push(`${option.key}. ${option.text}`)
|
|
30
30
|
}
|
|
31
31
|
lines.push('')
|
|
32
|
-
if (answerDetail) {
|
|
33
|
-
|
|
32
|
+
if (answerDetail?.answer?.trim()) {
|
|
33
|
+
const status=answerDetail.verified===true?'已通过独立校验,仍非官方认证':'尚未独立核验,可能有误,请先核对'
|
|
34
|
+
lines.push(`--- 参考答案(${status})---`)
|
|
34
35
|
lines.push(answerDetail.answer)
|
|
35
36
|
lines.push('')
|
|
36
|
-
lines.push(
|
|
37
|
-
lines.push(answerDetail.explanation)
|
|
37
|
+
lines.push(`--- 参考解析(${status})---`)
|
|
38
|
+
lines.push(answerDetail.explanation||'(尚未提供解析)')
|
|
38
39
|
} else {
|
|
39
40
|
lines.push('--- 答案状态 ---')
|
|
40
41
|
lines.push('本题的参考答案与解析尚未通过独立校验或尚未生成,请如实说明,不要给出确定的“标准答案”。')
|