daily-question 1.1.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 +251 -114
- package/client.js +804 -361
- package/index.js +2 -2
- package/lib/async.js +17 -0
- package/lib/bank.js +3 -3
- package/lib/exam-dates.js +10 -8
- package/lib/generate.js +124 -104
- 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 +3 -0
- package/lib/service.js +253 -60
- package/lib/store.js +3 -3
- 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,9 +142,11 @@ export class DailyQuestionService {
|
|
|
89
142
|
}
|
|
90
143
|
|
|
91
144
|
async bootstrap() {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
145
|
+
const now = new Date()
|
|
146
|
+
const today = todayISO(now)
|
|
147
|
+
const nextDayAt = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString()
|
|
94
148
|
let data = await this.data()
|
|
149
|
+
const options = yearOptions(today, data.examDates)
|
|
95
150
|
// 旧设置可能落在窗口外(它的初试早已结束):自动收敛到可选的最早一年
|
|
96
151
|
if (!options.includes(data.settings.targetYear)) {
|
|
97
152
|
await this.store.update((draft) => {
|
|
@@ -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,27 +225,36 @@ 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
|
}
|
|
179
239
|
|
|
180
240
|
isWrong(question, attempts) {
|
|
181
241
|
if (question.wrongBookRemoved) return false
|
|
182
|
-
|
|
183
|
-
return
|
|
242
|
+
if (question.wrongBookAdded) return true
|
|
243
|
+
return attempts.some((attempt) => attempt.questionId === question.id && attempt.result === 'wrong')
|
|
184
244
|
}
|
|
185
245
|
|
|
186
246
|
attemptsOf(data, qid) {
|
|
187
|
-
|
|
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)||[]
|
|
188
258
|
}
|
|
189
259
|
|
|
190
260
|
/* ------------------------------ 设置 ------------------------------ */
|
|
@@ -193,6 +263,9 @@ export class DailyQuestionService {
|
|
|
193
263
|
const data = await this.store.update((draft) => {
|
|
194
264
|
// 先落自定义科目,后面的 enabled 校验才能识别到它们。
|
|
195
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
|
+
}
|
|
196
269
|
draft.customSubjects = patch.customSubjects
|
|
197
270
|
.filter((item) => item && typeof item.id === 'string' && typeof item.name === 'string')
|
|
198
271
|
.map((item) => ({
|
|
@@ -202,11 +275,14 @@ export class DailyQuestionService {
|
|
|
202
275
|
types: normalizeCustomTypes(item.types),
|
|
203
276
|
outline: Array.isArray(item.outline) ? item.outline.filter((line) => typeof line === 'string').slice(0, 20) : [],
|
|
204
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) : '',
|
|
205
281
|
}))
|
|
206
282
|
}
|
|
207
283
|
if (patch.targetYear !== undefined) {
|
|
208
284
|
const year = Math.trunc(Number(patch.targetYear))
|
|
209
|
-
const allowed = yearOptions()
|
|
285
|
+
const allowed = yearOptions(todayISO(), draft.examDates)
|
|
210
286
|
if (!Number.isFinite(year) || !allowed.includes(year)) {
|
|
211
287
|
throw new Error(`考研年份只能选 ${allowed[0]}、${allowed[1]}、${allowed[2]}(初试已结束的年份不再提供)`)
|
|
212
288
|
}
|
|
@@ -268,14 +344,15 @@ export class DailyQuestionService {
|
|
|
268
344
|
return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
|
|
269
345
|
}
|
|
270
346
|
|
|
271
|
-
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))
|
|
272
349
|
this.inflightDaily.set(key, task)
|
|
273
350
|
const result = await task
|
|
274
351
|
const fresh = await this.data()
|
|
275
352
|
return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
|
|
276
353
|
}
|
|
277
354
|
|
|
278
|
-
async createDaily(subject, date, key) {
|
|
355
|
+
async createDaily(subject, date, key, signal) {
|
|
279
356
|
const data = await this.data()
|
|
280
357
|
const source = data.settings.subjectSource[subject.id] || 'auto'
|
|
281
358
|
const examInfo = resolveExamDate(data.settings.targetYear, data.examDates)
|
|
@@ -283,7 +360,7 @@ export class DailyQuestionService {
|
|
|
283
360
|
try {
|
|
284
361
|
if (source !== 'online') {
|
|
285
362
|
const picked = this.pickFromBank(data, subject, date)
|
|
286
|
-
if (picked) return await this.publishBankQuestion(picked, subject, date, key)
|
|
363
|
+
if (picked) return await this.publishBankQuestion(picked, subject, date, key, signal)
|
|
287
364
|
if (source === 'bank') {
|
|
288
365
|
throw new Error('题库中没有符合该科目要求的可用题目:请先导入题库,或把该科目的出题来源切换为在线出题')
|
|
289
366
|
}
|
|
@@ -293,6 +370,8 @@ export class DailyQuestionService {
|
|
|
293
370
|
ctx: this.ctx,
|
|
294
371
|
subject,
|
|
295
372
|
examInfo,
|
|
373
|
+
signal,
|
|
374
|
+
history:Object.values(data.questions).filter(question=>question.subjectId===subject.id),
|
|
296
375
|
})
|
|
297
376
|
const qid = `q_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`
|
|
298
377
|
const stamp = nowISO()
|
|
@@ -304,7 +383,9 @@ export class DailyQuestionService {
|
|
|
304
383
|
stem: generated.stem,
|
|
305
384
|
options: generated.options,
|
|
306
385
|
knowledge: generated.knowledge,
|
|
386
|
+
coverageTopic:generated.coverageTopic,
|
|
307
387
|
difficulty: generated.difficulty,
|
|
388
|
+
examAlignment: generated.examAlignment,
|
|
308
389
|
syllabusCheck: generated.syllabusCheck || '',
|
|
309
390
|
source: {
|
|
310
391
|
kind: 'online',
|
|
@@ -337,6 +418,9 @@ export class DailyQuestionService {
|
|
|
337
418
|
}
|
|
338
419
|
|
|
339
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('题目与已发布题目重复,已阻止发布,请重试生成新题')
|
|
340
424
|
draft.questions[qid] = record
|
|
341
425
|
draft.daily[key] = { date, subjectId: subject.id, questionId: qid, status: 'ready', createdAt: stamp }
|
|
342
426
|
delete draft.failures[subject.id]
|
|
@@ -344,6 +428,7 @@ export class DailyQuestionService {
|
|
|
344
428
|
})
|
|
345
429
|
return { questionId: qid, created: true }
|
|
346
430
|
} catch (error) {
|
|
431
|
+
if (signal?.aborted) throw signal.reason
|
|
347
432
|
const message = error instanceof QuestionGenerationError
|
|
348
433
|
? error.message
|
|
349
434
|
: `在线出题失败:${error?.message || error}`
|
|
@@ -371,17 +456,17 @@ export class DailyQuestionService {
|
|
|
371
456
|
}
|
|
372
457
|
if (!candidates.length) return null
|
|
373
458
|
candidates.sort((a, b) => String(a.lastAssignedDate).localeCompare(String(b.lastAssignedDate)))
|
|
374
|
-
const
|
|
375
|
-
const pool = fresh.length ? fresh : candidates
|
|
459
|
+
const pool = candidates.filter((candidate) => candidate.lastAssignedDate === candidates[0].lastAssignedDate)
|
|
376
460
|
const chosen = pool[Math.floor(Math.random() * pool.length)]
|
|
377
461
|
const repeated = Boolean(chosen.lastAssignedDate)
|
|
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,
|
|
@@ -540,6 +637,7 @@ export class DailyQuestionService {
|
|
|
540
637
|
type: question.type,
|
|
541
638
|
typeLabel: QUESTION_TYPE_LABEL[question.type] || question.type,
|
|
542
639
|
summary: summariseStem(question.stem),
|
|
640
|
+
stem: question.stem,
|
|
543
641
|
assignedDate: question.assignedDate,
|
|
544
642
|
attemptCount: attempts.length,
|
|
545
643
|
favorite: Boolean(question.favoriteAt),
|
|
@@ -551,9 +649,11 @@ export class DailyQuestionService {
|
|
|
551
649
|
}
|
|
552
650
|
}
|
|
553
651
|
|
|
554
|
-
async listQuestions({ subjectId, kind = 'all' } = {}) {
|
|
652
|
+
async listQuestions({ subjectId, kind = 'all', page = 1, pageSize = 50, idsOnly = false } = {}) {
|
|
555
653
|
const data = await this.data()
|
|
556
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道题')
|
|
557
657
|
let questions = Object.values(data.questions).filter((question) => question.subjectId === subjectId)
|
|
558
658
|
if (kind === 'favorite') questions = questions.filter((question) => question.favoriteAt)
|
|
559
659
|
if (kind === 'wrong') {
|
|
@@ -567,12 +667,16 @@ export class DailyQuestionService {
|
|
|
567
667
|
questions = questions.filter((question) => question.id === record?.questionId)
|
|
568
668
|
}
|
|
569
669
|
questions.sort((a, b) => String(b.assignedDate).localeCompare(String(a.assignedDate))
|
|
570
|
-
|| 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)
|
|
571
674
|
return {
|
|
572
675
|
ok: true,
|
|
573
676
|
kind,
|
|
574
677
|
subjectId,
|
|
575
|
-
|
|
678
|
+
total,page,pageSize,totalPages,
|
|
679
|
+
items: questions.slice((page-1)*pageSize,page*pageSize).map((question) => this.summaryView(data, question)),
|
|
576
680
|
}
|
|
577
681
|
}
|
|
578
682
|
|
|
@@ -581,7 +685,9 @@ export class DailyQuestionService {
|
|
|
581
685
|
const question = data.questions[qid]
|
|
582
686
|
if (!question) throw new Error('题目不存在(可能已随题库删除而失效)')
|
|
583
687
|
const thread = this.threadView(data, qid)
|
|
584
|
-
|
|
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 }
|
|
585
691
|
}
|
|
586
692
|
|
|
587
693
|
threadView(data, qid) {
|
|
@@ -600,12 +706,6 @@ export class DailyQuestionService {
|
|
|
600
706
|
const data = await this.data()
|
|
601
707
|
const question = data.questions[qid]
|
|
602
708
|
if (!question) throw new Error('题目不存在')
|
|
603
|
-
if (attemptId) {
|
|
604
|
-
const existing = data.attempts.find((attempt) => attempt.id === attemptId)
|
|
605
|
-
if (existing) {
|
|
606
|
-
return { ok: true, question: this.questionView(await this.data(), question), duplicated: true }
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
709
|
const isChoice = CHOICE_TYPES.has(question.type)
|
|
610
710
|
let result = 'pending'
|
|
611
711
|
let normalizedChoice = ''
|
|
@@ -613,7 +713,9 @@ export class DailyQuestionService {
|
|
|
613
713
|
if (!isChoice) throw new Error('主观题不支持提交选项')
|
|
614
714
|
normalizedChoice = normalizeChoice(choice, question)
|
|
615
715
|
const key = sortLetters(question.currentAnswer?.answerKey)
|
|
616
|
-
|
|
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'
|
|
617
719
|
} else if (mode === 'giveup') {
|
|
618
720
|
result = 'wrong'
|
|
619
721
|
} else if (mode === 'done') {
|
|
@@ -627,42 +729,53 @@ export class DailyQuestionService {
|
|
|
627
729
|
id: attemptId || randomUUID(),
|
|
628
730
|
questionId: qid,
|
|
629
731
|
at: stamp,
|
|
732
|
+
practiceDate:todayISO(new Date(stamp)),
|
|
630
733
|
type: question.type,
|
|
631
734
|
mode,
|
|
632
735
|
choice: normalizedChoice,
|
|
633
736
|
result,
|
|
634
737
|
revealedAt: stamp,
|
|
635
|
-
judgedAt:
|
|
738
|
+
judgedAt: result === 'pending' ? null : stamp,
|
|
636
739
|
}
|
|
637
740
|
|
|
638
|
-
await this.store.update((draft) => {
|
|
741
|
+
const duplicated = await this.store.update((draft) => {
|
|
639
742
|
const target = draft.questions[qid]
|
|
640
743
|
if (!target) throw new Error('题目不存在')
|
|
744
|
+
const existing = draft.attempts.find((item) => item.id === attempt.id)
|
|
745
|
+
if (existing) {
|
|
746
|
+
if (existing.questionId !== qid) throw new Error('作答 ID 已用于其他题目')
|
|
747
|
+
return true
|
|
748
|
+
}
|
|
641
749
|
draft.attempts.push(attempt)
|
|
642
750
|
if (target.currentAnswer && !target.currentAnswer.firstShownAt) {
|
|
643
751
|
target.currentAnswer.firstShownAt = stamp
|
|
644
752
|
}
|
|
645
753
|
target.updatedAt = stamp
|
|
646
|
-
return
|
|
754
|
+
return false
|
|
647
755
|
})
|
|
648
756
|
const fresh = await this.data()
|
|
649
|
-
return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question) }
|
|
757
|
+
return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question, {attemptId:attempt.id}), duplicated }
|
|
650
758
|
}
|
|
651
759
|
|
|
652
|
-
async judge({ qid, verdict }) {
|
|
760
|
+
async judge({ qid, verdict, attemptId }) {
|
|
653
761
|
if (!['correct', 'wrong'].includes(verdict)) throw new Error('判定结果不合法')
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
}
|
|
658
771
|
target.result = verdict
|
|
659
772
|
target.judgedAt = nowISO()
|
|
660
773
|
const question = draft.questions[qid]
|
|
661
774
|
if (question) question.updatedAt = nowISO()
|
|
662
|
-
return
|
|
775
|
+
return false
|
|
663
776
|
})
|
|
664
777
|
const data = await this.data()
|
|
665
|
-
return { ok: true, question: this.questionView(data, data.questions[qid]) }
|
|
778
|
+
return { ok: true, question: this.questionView(data, data.questions[qid], {attemptId}), duplicated }
|
|
666
779
|
}
|
|
667
780
|
|
|
668
781
|
async favorite({ qid, value }) {
|
|
@@ -689,9 +802,54 @@ export class DailyQuestionService {
|
|
|
689
802
|
return { ok: true, question: this.questionView(data, data.questions[qid]), counts: this.counts(data) }
|
|
690
803
|
}
|
|
691
804
|
|
|
805
|
+
async bulkQuestions({ qids, action, confirmProtected = false }) {
|
|
806
|
+
if (!Array.isArray(qids) || !qids.length || qids.some((id) => typeof id !== 'string' || !id)) {
|
|
807
|
+
throw new Error('请选择需要管理的题目')
|
|
808
|
+
}
|
|
809
|
+
if (!['delete', 'favorite', 'unfavorite', 'wrong', 'unwrong'].includes(action)) throw new Error('未知的批量操作')
|
|
810
|
+
const ids = new Set(qids)
|
|
811
|
+
const result = await this.store.update((draft) => {
|
|
812
|
+
const questions = [...ids].map((id) => {
|
|
813
|
+
if (!Object.hasOwn(draft.questions, id)) throw new Error('部分题目已不存在,请刷新列表后重试')
|
|
814
|
+
return draft.questions[id]
|
|
815
|
+
})
|
|
816
|
+
const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, this.attemptsOf(draft,q.id))).length
|
|
817
|
+
if (action === 'delete' && protectedCount && confirmProtected !== true) {
|
|
818
|
+
return { requiresConfirmation: true, protectedCount }
|
|
819
|
+
}
|
|
820
|
+
const stamp = nowISO()
|
|
821
|
+
for (const q of questions) {
|
|
822
|
+
if (action === 'delete') {
|
|
823
|
+
delete draft.questions[q.id]
|
|
824
|
+
delete draft.threads[q.id]
|
|
825
|
+
} else {
|
|
826
|
+
if (action === 'favorite') q.favoriteAt = stamp
|
|
827
|
+
if (action === 'unfavorite') q.favoriteAt = null
|
|
828
|
+
if (action === 'wrong' || action === 'unwrong') {
|
|
829
|
+
q.wrongBookAdded = action === 'wrong'
|
|
830
|
+
q.wrongBookRemoved = action === 'unwrong'
|
|
831
|
+
}
|
|
832
|
+
q.updatedAt = stamp
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (action === 'delete') {
|
|
836
|
+
draft.attempts = draft.attempts.filter((attempt) => !ids.has(attempt.questionId))
|
|
837
|
+
for (const [key, record] of Object.entries(draft.daily)) {
|
|
838
|
+
if (ids.has(record.questionId)) delete draft.daily[key]
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return { affected: ids.size }
|
|
842
|
+
})
|
|
843
|
+
return { ok: true, ...result, counts: this.counts(await this.data()) }
|
|
844
|
+
}
|
|
845
|
+
|
|
692
846
|
/* ------------------------------ 答案修订 ------------------------------ */
|
|
693
847
|
|
|
694
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) {
|
|
695
853
|
const data = await this.data()
|
|
696
854
|
const question = data.questions[qid]
|
|
697
855
|
if (!question) throw new Error('题目不存在')
|
|
@@ -704,6 +862,7 @@ export class DailyQuestionService {
|
|
|
704
862
|
question,
|
|
705
863
|
currentAnswer: question.currentAnswer,
|
|
706
864
|
reason,
|
|
865
|
+
signal,
|
|
707
866
|
})
|
|
708
867
|
const candidate = {
|
|
709
868
|
candidateId: `c_${randomUUID().slice(0, 8)}`,
|
|
@@ -716,6 +875,7 @@ export class DailyQuestionService {
|
|
|
716
875
|
}
|
|
717
876
|
await this.store.update((draft) => {
|
|
718
877
|
const target = draft.questions[qid]
|
|
878
|
+
signal.throwIfAborted()
|
|
719
879
|
if (!target) throw new Error('题目不存在')
|
|
720
880
|
target.pendingRevision = candidate
|
|
721
881
|
return draft
|
|
@@ -778,6 +938,24 @@ export class DailyQuestionService {
|
|
|
778
938
|
/* ------------------------------ 追问 ------------------------------ */
|
|
779
939
|
|
|
780
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) {
|
|
781
959
|
const text = String(message || '').trim()
|
|
782
960
|
if (!text) throw new Error('追问内容不能为空')
|
|
783
961
|
if (text.length > 4000) throw new Error('追问内容过长,请精简后再发送')
|
|
@@ -796,15 +974,20 @@ export class DailyQuestionService {
|
|
|
796
974
|
const messageId = failed ? failed.id : (clientMessageId || randomUUID())
|
|
797
975
|
|
|
798
976
|
// 先落盘用户消息:即使回复失败,用户输入也不会丢,并且可以按同一 id 重试。
|
|
799
|
-
await this.store.update((draft) => {
|
|
977
|
+
const queued = await this.store.update((draft) => {
|
|
978
|
+
signal.throwIfAborted()
|
|
979
|
+
if (!draft.questions[qid]) throw new Error('题目已删除')
|
|
800
980
|
if (!draft.threads[qid]) {
|
|
801
|
-
draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: stamp }
|
|
981
|
+
draft.threads[qid] = { threadId: qid, questionId: qid, generation: randomUUID(), messages: [], updatedAt: stamp }
|
|
802
982
|
}
|
|
803
983
|
const thread = draft.threads[qid]
|
|
984
|
+
if (!thread.generation) thread.generation = randomUUID()
|
|
804
985
|
const target = thread.messages.find((item) => item.id === messageId)
|
|
805
986
|
if (target) {
|
|
987
|
+
if (target.status !== 'failed') return { duplicated: true, generation: thread.generation }
|
|
806
988
|
target.text = text
|
|
807
989
|
target.status = 'pending'
|
|
990
|
+
target.executionId = executionId
|
|
808
991
|
} else {
|
|
809
992
|
thread.messages.push({
|
|
810
993
|
id: messageId,
|
|
@@ -814,17 +997,19 @@ export class DailyQuestionService {
|
|
|
814
997
|
text,
|
|
815
998
|
createdAt: stamp,
|
|
816
999
|
status: 'pending',
|
|
1000
|
+
executionId,
|
|
817
1001
|
})
|
|
818
1002
|
}
|
|
819
1003
|
thread.updatedAt = stamp
|
|
820
|
-
return
|
|
1004
|
+
return { duplicated: false, generation: thread.generation }
|
|
821
1005
|
})
|
|
1006
|
+
if (queued.duplicated) return { ok: true, thread: this.threadView(await this.data(), qid), duplicated: true }
|
|
822
1007
|
|
|
823
1008
|
const history = (await this.data()).threads[qid]?.messages || []
|
|
824
1009
|
const setStatus = async (status) => {
|
|
825
1010
|
await this.store.update((draft) => {
|
|
826
1011
|
const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
|
|
827
|
-
if (target) target.status = status
|
|
1012
|
+
if (target?.executionId === executionId) target.status = status
|
|
828
1013
|
return draft
|
|
829
1014
|
}).catch(() => {})
|
|
830
1015
|
}
|
|
@@ -836,6 +1021,7 @@ export class DailyQuestionService {
|
|
|
836
1021
|
answerDetail: question.currentAnswer,
|
|
837
1022
|
history: history.filter((item) => item.id !== messageId),
|
|
838
1023
|
message: text,
|
|
1024
|
+
signal,
|
|
839
1025
|
})
|
|
840
1026
|
const assistantMessage = {
|
|
841
1027
|
id: randomUUID(),
|
|
@@ -847,10 +1033,12 @@ export class DailyQuestionService {
|
|
|
847
1033
|
status: 'sent',
|
|
848
1034
|
}
|
|
849
1035
|
await this.store.update((draft) => {
|
|
850
|
-
|
|
1036
|
+
signal.throwIfAborted()
|
|
1037
|
+
if (!draft.questions[qid]) throw new Error('题目已删除')
|
|
851
1038
|
const thread = draft.threads[qid]
|
|
852
|
-
const target = thread
|
|
853
|
-
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'
|
|
854
1042
|
thread.messages.push(assistantMessage)
|
|
855
1043
|
thread.updatedAt = assistantMessage.createdAt
|
|
856
1044
|
return draft
|
|
@@ -865,9 +1053,14 @@ export class DailyQuestionService {
|
|
|
865
1053
|
|
|
866
1054
|
async clearThread({ qid }) {
|
|
867
1055
|
await this.store.update((draft) => {
|
|
868
|
-
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
|
+
}
|
|
869
1061
|
return draft
|
|
870
1062
|
})
|
|
1063
|
+
await this.cancelTask({kind:'followup',qid})
|
|
871
1064
|
const data = await this.data()
|
|
872
1065
|
return { ok: true, thread: this.threadView(data, qid) }
|
|
873
1066
|
}
|
|
@@ -881,7 +1074,7 @@ export class DailyQuestionService {
|
|
|
881
1074
|
if (!base64) throw new Error('缺少 PDF 文件内容')
|
|
882
1075
|
const bytes = Buffer.from(String(base64), 'base64')
|
|
883
1076
|
if (bytes.length > 40 * 1024 * 1024) throw new Error('PDF 文件过大(上限 40MB)')
|
|
884
|
-
const extracted = extractBankPdf(bytes, fileName || name || '')
|
|
1077
|
+
const extracted = await extractBankPdf(bytes, fileName || name || '')
|
|
885
1078
|
content = extracted.text
|
|
886
1079
|
warnings = extracted.warnings
|
|
887
1080
|
} else {
|