daily-question 2.0.0 → 2.1.1

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/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, hasChoiceOptions, validChoiceKey, 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
- import { parseBankText, extractBankPdf } from './bank.js'
6
+ import { parseBankText, extractBankPdf, isUsableBankItem } 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
  * 都允许,只做去空、去重、限长限量。
@@ -47,6 +56,8 @@ function normalizeChoice(value, question) {
47
56
  for (const letter of upper) {
48
57
  if (!keys.includes(letter)) throw new Error(`选项 ${letter} 不在本题选项中`)
49
58
  }
59
+ if (question.type !== 'multiple' && upper.length !== 1) throw new Error('本题只能选择一个选项')
60
+ if (new Set(upper).size !== upper.length) throw new Error('选项不能重复')
50
61
  return upper.split('').sort().join('')
51
62
  }
52
63
 
@@ -62,21 +73,65 @@ function sortLetters(value) {
62
73
  * 后续可能的 Agent 工具共享同一套业务逻辑。
63
74
  */
64
75
  export class DailyQuestionService {
65
- constructor(ctx) {
76
+ constructor(ctx, { taskTimeoutMs } = {}) {
66
77
  this.ctx = ctx
67
78
  this.store = new Store(resolveDataDir())
68
79
  this.inflightDaily = new Map()
80
+ this.inflightFollowups = new Map()
81
+ this.recovery = null
82
+ this.attemptIndexes = new WeakMap()
83
+ this.tasks = new Map()
84
+ this.taskTimeoutMs = taskTimeoutMs
69
85
  this.imports = new Map()
86
+ this.checkUpdates = createUpdateChecker()
70
87
  this.log = typeof ctx.logger === 'function' ? ctx.logger('daily-question') : (ctx.logger || console)
71
88
  }
72
89
 
73
90
  async init() {
74
- await this.store.load()
91
+ await this.data()
92
+ }
93
+
94
+ runTask(key, details, run) {
95
+ if (this.tasks.has(key)) return this.tasks.get(key).promise
96
+ const job = { ...details, controller: new AbortController() }
97
+ job.promise = withTimeout(run, { signal: job.controller.signal,
98
+ timeoutMs: this.taskTimeoutMs ?? (details.kind === 'daily' ? 15*60*1000 : details.kind === 'revise' ? 10*60*1000 : 5*60*1000),
99
+ message: '任务超时,已停止本次生成,可以重试',
100
+ }).finally(() => { if (this.tasks.get(key) === job) this.tasks.delete(key) })
101
+ this.tasks.set(key, job)
102
+ return job.promise
103
+ }
104
+
105
+ async cancelTask({ kind, subjectId, qid, clientMessageId }) {
106
+ if (!['daily','revise','followup'].includes(kind)) throw new Error('不支持取消此类任务')
107
+ const jobs = [...this.tasks.values()].filter((job) => job.kind === kind && (kind === 'daily'
108
+ ? Boolean(subjectId) && job.subjectId === subjectId
109
+ : Boolean(qid) && job.qid === qid && (kind !== 'followup' || !clientMessageId || job.clientMessageId === clientMessageId)))
110
+ for (const job of jobs) job.controller.abort(Object.assign(new Error('任务已取消,可以重试'), { code:'TASK_CANCELLED' }))
111
+ await Promise.allSettled(jobs.map((job) => job.promise))
112
+ return { ok: true, cancelled: jobs.length }
113
+ }
114
+
115
+ dispose() {
116
+ for (const job of this.tasks.values()) job.controller.abort(new Error('插件已停止,本次任务已取消'))
75
117
  }
76
118
 
77
119
  /* ------------------------------ 读取与状态 ------------------------------ */
78
120
 
79
121
  async data() {
122
+ if (!this.recovery) {
123
+ this.recovery = (async () => {
124
+ const data = await this.store.read()
125
+ if (Object.values(data.threads).some((thread) => thread.messages?.some((message) => message.status === 'pending'))) {
126
+ await this.store.update((draft) => {
127
+ for (const thread of Object.values(draft.threads)) {
128
+ for (const message of thread.messages || []) if (message.status === 'pending') message.status = 'failed'
129
+ }
130
+ })
131
+ }
132
+ })().catch((error) => { this.recovery = null; throw error })
133
+ }
134
+ await this.recovery
80
135
  return this.store.read()
81
136
  }
82
137
 
@@ -89,7 +144,9 @@ export class DailyQuestionService {
89
144
  }
90
145
 
91
146
  async bootstrap() {
92
- const today = todayISO()
147
+ const now = new Date()
148
+ const today = todayISO(now)
149
+ const nextDayAt = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString()
93
150
  let data = await this.data()
94
151
  const options = yearOptions(today, data.examDates)
95
152
  // 旧设置可能落在窗口外(它的初试早已结束):自动收敛到可选的最早一年
@@ -112,12 +169,15 @@ export class DailyQuestionService {
112
169
  return {
113
170
  ok: true,
114
171
  dataFile: this.store.file,
172
+ pluginVersion: PLUGIN_VERSION,
115
173
  dataError: this.store.lastError,
116
174
  today,
117
- now: nowISO(),
175
+ now: now.toISOString(),
176
+ nextDayAt,
118
177
  yearOptions: options,
119
178
  settings: data.settings,
120
179
  customSubjects: data.customSubjects,
180
+ referenceExams: REFERENCE_EXAMS,
121
181
  examInfo,
122
182
  countdown,
123
183
  subjects: this.subjectViews(data),
@@ -140,10 +200,13 @@ export class DailyQuestionService {
140
200
  types: subject.types,
141
201
  typeLabels: subject.types.map((type) => QUESTION_TYPE_LABEL[type] || type),
142
202
  custom: Boolean(subject.custom),
203
+ referenceSubjectId: subject.referenceSubjectId || '',
204
+ referenceExamName: subject.referenceExamName || '',
205
+ referenceExamCode: subject.referenceExamCode || '',
143
206
  enabled: enabled.has(subject.id),
144
207
  source: data.settings.subjectSource[subject.id] || 'auto',
145
208
  bankItemCount: usableBanks.reduce((total, bank) => total
146
- + bank.items.filter((item) => item.stem && (!item.type || allowed.has(item.type))).length, 0),
209
+ + bank.items.filter((item) => isUsableBankItem(item) && (!item.type || allowed.has(item.type))).length, 0),
147
210
  }
148
211
  })
149
212
  }
@@ -157,35 +220,42 @@ export class DailyQuestionService {
157
220
  subjectId: bank.subjectId || '',
158
221
  importedAt: bank.importedAt,
159
222
  itemCount: bank.items.length,
160
- usable: bank.items.filter((item) => item.stem).length,
223
+ usable: bank.items.filter(isUsableBankItem).length,
161
224
  pendingAnswers: bank.items.filter((item) => item.answerStatus !== 'verified').length,
162
225
  }))
163
226
  }
164
227
 
165
228
  counts(data) {
166
- const result = {}
167
- for (const subjectId of data.settings.subjects) {
168
- const questions = Object.values(data.questions).filter((question) => question.subjectId === subjectId)
169
- const ids = new Set(questions.map((question) => question.id))
170
- const attempts = data.attempts.filter((attempt) => ids.has(attempt.questionId))
171
- result[subjectId] = {
172
- all: questions.length,
173
- favorite: questions.filter((question) => question.favoriteAt).length,
174
- wrong: questions.filter((question) => this.isWrong(question, attempts)).length,
175
- }
229
+ const result = Object.create(null)
230
+ for (const id of data.settings.subjects) result[id] = { all: 0, favorite: 0, wrong: 0 }
231
+ for(const question of Object.values(data.questions)) {
232
+ if (!question.subjectId) continue
233
+ const count = result[question.subjectId] ??= { all: 0, favorite: 0, wrong: 0 }
234
+ count.all++
235
+ if(question.favoriteAt)count.favorite++
236
+ if(this.isWrong(question,this.attemptsOf(data,question.id)))count.wrong++
176
237
  }
177
- return result
238
+ return { ...result }
178
239
  }
179
240
 
180
241
  isWrong(question, attempts) {
181
242
  if (question.wrongBookRemoved) return false
182
243
  if (question.wrongBookAdded) return true
183
- const own = attempts.filter((attempt) => attempt.questionId === question.id)
184
- return own.some((attempt) => attempt.result === 'wrong')
244
+ return attempts.some((attempt) => attempt.questionId === question.id && attempt.result === 'wrong')
185
245
  }
186
246
 
187
247
  attemptsOf(data, qid) {
188
- return data.attempts.filter((attempt) => attempt.questionId === qid)
248
+ let index=this.attemptIndexes.get(data)
249
+ if(!index||index.source!==data.attempts||index.length!==data.attempts.length) {
250
+ const byQuestion=new Map()
251
+ for(const attempt of data.attempts) {
252
+ if(!byQuestion.has(attempt.questionId))byQuestion.set(attempt.questionId,[])
253
+ byQuestion.get(attempt.questionId).push(attempt)
254
+ }
255
+ index={source:data.attempts,length:data.attempts.length,byQuestion}
256
+ this.attemptIndexes.set(data,index)
257
+ }
258
+ return index.byQuestion.get(qid)||[]
189
259
  }
190
260
 
191
261
  /* ------------------------------ 设置 ------------------------------ */
@@ -194,6 +264,9 @@ export class DailyQuestionService {
194
264
  const data = await this.store.update((draft) => {
195
265
  // 先落自定义科目,后面的 enabled 校验才能识别到它们。
196
266
  if (Array.isArray(patch.customSubjects)) {
267
+ if (patch.customSubjects.some((item) => item?.referenceSubjectId === 'independent' && !(typeof item.referenceExamName === 'string' && item.referenceExamName.trim()))) {
268
+ throw new Error('独立参考考试请填写准确的考试名称(院校自命题请包含院校名称)')
269
+ }
197
270
  draft.customSubjects = patch.customSubjects
198
271
  .filter((item) => item && typeof item.id === 'string' && typeof item.name === 'string')
199
272
  .map((item) => ({
@@ -203,6 +276,9 @@ export class DailyQuestionService {
203
276
  types: normalizeCustomTypes(item.types),
204
277
  outline: Array.isArray(item.outline) ? item.outline.filter((line) => typeof line === 'string').slice(0, 20) : [],
205
278
  guidance: typeof item.guidance === 'string' ? item.guidance : '',
279
+ referenceSubjectId: ['independent','auto'].includes(item.referenceSubjectId) ? item.referenceSubjectId : REFERENCE_EXAMS.find((exam) => exam.id === item.referenceSubjectId)?.id || '',
280
+ referenceExamName: typeof item.referenceExamName === 'string' ? item.referenceExamName.trim().slice(0,100) : '',
281
+ referenceExamCode: typeof item.referenceExamCode === 'string' ? item.referenceExamCode.trim().slice(0,20) : '',
206
282
  }))
207
283
  }
208
284
  if (patch.targetYear !== undefined) {
@@ -269,14 +345,15 @@ export class DailyQuestionService {
269
345
  return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
270
346
  }
271
347
 
272
- const task = this.createDaily(subject, date, key).finally(() => this.inflightDaily.delete(key))
348
+ const task = this.runTask('daily:'+key, {kind:'daily',subjectId:subject.id},
349
+ (signal) => this.createDaily(subject, date, key, signal)).finally(() => this.inflightDaily.delete(key))
273
350
  this.inflightDaily.set(key, task)
274
351
  const result = await task
275
352
  const fresh = await this.data()
276
353
  return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
277
354
  }
278
355
 
279
- async createDaily(subject, date, key) {
356
+ async createDaily(subject, date, key, signal) {
280
357
  const data = await this.data()
281
358
  const source = data.settings.subjectSource[subject.id] || 'auto'
282
359
  const examInfo = resolveExamDate(data.settings.targetYear, data.examDates)
@@ -284,7 +361,7 @@ export class DailyQuestionService {
284
361
  try {
285
362
  if (source !== 'online') {
286
363
  const picked = this.pickFromBank(data, subject, date)
287
- if (picked) return await this.publishBankQuestion(picked, subject, date, key)
364
+ if (picked) return await this.publishBankQuestion(picked, subject, date, key, signal)
288
365
  if (source === 'bank') {
289
366
  throw new Error('题库中没有符合该科目要求的可用题目:请先导入题库,或把该科目的出题来源切换为在线出题')
290
367
  }
@@ -294,6 +371,8 @@ export class DailyQuestionService {
294
371
  ctx: this.ctx,
295
372
  subject,
296
373
  examInfo,
374
+ signal,
375
+ history:Object.values(data.questions).filter(question=>question.subjectId===subject.id),
297
376
  })
298
377
  const qid = `q_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`
299
378
  const stamp = nowISO()
@@ -305,7 +384,9 @@ export class DailyQuestionService {
305
384
  stem: generated.stem,
306
385
  options: generated.options,
307
386
  knowledge: generated.knowledge,
387
+ coverageTopic:generated.coverageTopic,
308
388
  difficulty: generated.difficulty,
389
+ examAlignment: generated.examAlignment,
309
390
  syllabusCheck: generated.syllabusCheck || '',
310
391
  source: {
311
392
  kind: 'online',
@@ -338,6 +419,10 @@ export class DailyQuestionService {
338
419
  }
339
420
 
340
421
  await this.store.update((draft) => {
422
+ signal?.throwIfAborted()
423
+ if (!draft.settings.subjects.includes(subject.id)) throw new Error('该科目已停用')
424
+ const history=Object.values(draft.questions).filter(question=>question.subjectId===subject.id)
425
+ if(findQuestionRepeat(generated,indexQuestionHistory(history)))throw new Error('题目与已发布题目重复,已阻止发布,请重试生成新题')
341
426
  draft.questions[qid] = record
342
427
  draft.daily[key] = { date, subjectId: subject.id, questionId: qid, status: 'ready', createdAt: stamp }
343
428
  delete draft.failures[subject.id]
@@ -345,12 +430,12 @@ export class DailyQuestionService {
345
430
  })
346
431
  return { questionId: qid, created: true }
347
432
  } catch (error) {
433
+ if (signal?.aborted) throw signal.reason
348
434
  const message = error instanceof QuestionGenerationError
349
435
  ? error.message
350
436
  : `在线出题失败:${error?.message || error}`
351
437
  await this.store.update((draft) => {
352
- draft.failures[subject.id] = { at: nowISO(), message }
353
- return draft
438
+ if (draft.settings.subjects.includes(subject.id)) draft.failures[subject.id] = { at: nowISO(), message }
354
439
  }).catch(() => {})
355
440
  throw new Error(message)
356
441
  }
@@ -362,7 +447,7 @@ export class DailyQuestionService {
362
447
  for (const bank of data.banks) {
363
448
  if (bank.subjectId && bank.subjectId !== subject.id) continue
364
449
  for (const item of bank.items) {
365
- if (!item.stem) continue
450
+ if (!isUsableBankItem(item)) continue
366
451
  const qid = bankQuestionId(bank.id, item.id)
367
452
  const existing = data.questions[qid]
368
453
  if (existing && existing.subjectId !== subject.id) continue
@@ -378,10 +463,12 @@ export class DailyQuestionService {
378
463
  return { ...chosen, repeated }
379
464
  }
380
465
 
381
- async publishBankQuestion(picked, subject, date, key) {
466
+ async publishBankQuestion(picked, subject, date, key, signal) {
382
467
  const { bank, item, qid, repeated } = picked
383
468
  const stamp = nowISO()
384
469
  await this.store.update((draft) => {
470
+ signal?.throwIfAborted()
471
+ if (!draft.settings.subjects.includes(subject.id)) throw new Error('该科目已停用')
385
472
  const existing = draft.questions[qid]
386
473
  if (existing) {
387
474
  existing.assignedCount = (existing.assignedCount || 1) + 1
@@ -454,11 +541,15 @@ export class DailyQuestionService {
454
541
 
455
542
  /* ------------------------------ 题目视图 ------------------------------ */
456
543
 
457
- questionView(data, question) {
544
+ questionView(data, question, { attemptId, date = todayISO() } = {}) {
458
545
  const attempts = this.attemptsOf(data, question.id)
459
- const revealed = attempts.length > 0
460
- const last = attempts[attempts.length - 1] || null
546
+ const isToday=data.daily[`${date}|${question.subjectId}`]?.questionId===question.id
547
+ const currentAttempts=isToday?attempts.filter(attempt=>attemptDate(attempt)===date):attempts
548
+ const revealed = currentAttempts.length > 0
549
+ const last = (attemptId ? currentAttempts.find((attempt) => attempt.id === attemptId) : currentAttempts[currentAttempts.length - 1]) || null
461
550
  const answer = question.currentAnswer
551
+ const isChoice = CHOICE_TYPES.has(question.type)
552
+ const canSelect = isChoice && hasChoiceOptions(question.options)
462
553
  return {
463
554
  id: question.id,
464
555
  subjectId: question.subjectId,
@@ -466,13 +557,23 @@ export class DailyQuestionService {
466
557
  type: question.type,
467
558
  typeLabel: QUESTION_TYPE_LABEL[question.type] || question.type,
468
559
  /** 完形填空 / 阅读理解按选择题判分,前端据此渲染选项而不是“作答完毕”。 */
469
- isChoice: CHOICE_TYPES.has(question.type),
560
+ isChoice,
561
+ canSelect,
470
562
  stem: question.stem,
471
563
  options: question.options || [],
472
564
  knowledge: question.knowledge || [],
473
565
  difficulty: question.difficulty || 3,
566
+ examAlignment: question.examAlignment ? {
567
+ ...question.examAlignment,
568
+ rationale: revealed ? question.examAlignment.rationale : '',
569
+ warnings: revealed ? question.examAlignment.warnings || [] : [],
570
+ outlineEvidence: revealed ? question.examAlignment.outlineEvidence : [],
571
+ paperEvidence: revealed ? question.examAlignment.paperEvidence : [],
572
+ } : null,
474
573
  source: question.source,
475
- assignedDate: question.assignedDate,
574
+ assignedDate: isToday ? date : question.lastAssignedDate || question.assignedDate,
575
+ practiceDate:isToday?date:null,
576
+ todayAttemptCount:isToday?currentAttempts.length:0,
476
577
  createdAt: question.createdAt,
477
578
  assignedCount: question.assignedCount || 1,
478
579
  repeated: Boolean(question.repeated),
@@ -485,6 +586,7 @@ export class DailyQuestionService {
485
586
  mode: attempt.mode,
486
587
  choice: attempt.choice || '',
487
588
  judgedAt: attempt.judgedAt || null,
589
+ practiceDate:attemptDate(attempt),
488
590
  })),
489
591
  attemptCount: attempts.length,
490
592
  lastAttempt: last ? { id: last.id, at: last.at, result: last.result, mode: last.mode, choice: last.choice || '' } : null,
@@ -505,7 +607,7 @@ export class DailyQuestionService {
505
607
  ? {
506
608
  versionId: answer.versionId,
507
609
  answer: answer.answer,
508
- answerKey: answer.answerKey || '',
610
+ answerKey: canSelect && validChoiceKey(question.type, answer.answerKey, question.options) ? answer.answerKey : '',
509
611
  explanation: answer.explanation,
510
612
  verified: Boolean(answer.verified),
511
613
  basis: answer.basis,
@@ -541,7 +643,7 @@ export class DailyQuestionService {
541
643
  typeLabel: QUESTION_TYPE_LABEL[question.type] || question.type,
542
644
  summary: summariseStem(question.stem),
543
645
  stem: question.stem,
544
- assignedDate: question.assignedDate,
646
+ assignedDate: question.lastAssignedDate || question.assignedDate,
545
647
  attemptCount: attempts.length,
546
648
  favorite: Boolean(question.favoriteAt),
547
649
  wrongEver: this.isWrong(question, attempts),
@@ -552,9 +654,11 @@ export class DailyQuestionService {
552
654
  }
553
655
  }
554
656
 
555
- async listQuestions({ subjectId, kind = 'all' } = {}) {
657
+ async listQuestions({ subjectId, kind = 'all', page = 1, pageSize = 50, idsOnly = false } = {}) {
556
658
  const data = await this.data()
557
659
  if (!subjectId) throw new Error('缺少科目')
660
+ if(!['all','favorite','wrong','today'].includes(kind))throw new Error('未知题目栏目')
661
+ if(!Number.isInteger(page)||page<1||!Number.isInteger(pageSize)||pageSize<1||pageSize>100)throw new Error('分页参数无效,每页最多100道题')
558
662
  let questions = Object.values(data.questions).filter((question) => question.subjectId === subjectId)
559
663
  if (kind === 'favorite') questions = questions.filter((question) => question.favoriteAt)
560
664
  if (kind === 'wrong') {
@@ -567,23 +671,28 @@ export class DailyQuestionService {
567
671
  const record = data.daily[`${todayISO()}|${subjectId}`]
568
672
  questions = questions.filter((question) => question.id === record?.questionId)
569
673
  }
570
- questions.sort((a, b) => String(b.assignedDate).localeCompare(String(a.assignedDate))
571
- || String(b.createdAt).localeCompare(String(a.createdAt)))
674
+ questions.sort((a, b) => String(b.lastAssignedDate || b.assignedDate).localeCompare(String(a.lastAssignedDate || a.assignedDate))
675
+ || String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id)))
676
+ const total=questions.length,totalPages=Math.max(1,Math.ceil(total/pageSize))
677
+ if(idsOnly===true)return {ok:true,kind,subjectId,total,ids:questions.map(q=>q.id)}
678
+ page=Math.min(page,totalPages)
572
679
  return {
573
680
  ok: true,
574
681
  kind,
575
682
  subjectId,
576
- items: questions.map((question) => this.summaryView(data, question)),
683
+ total,page,pageSize,totalPages,
684
+ items: questions.slice((page-1)*pageSize,page*pageSize).map((question) => this.summaryView(data, question)),
577
685
  }
578
686
  }
579
687
 
580
- async getQuestion({ qid }) {
688
+ async getQuestion({ qid, attemptId }) {
581
689
  const data = await this.data()
582
690
  const question = data.questions[qid]
583
691
  if (!question) throw new Error('题目不存在(可能已随题库删除而失效)')
584
692
  const thread = this.threadView(data, qid)
585
- const isToday = data.daily[`${todayISO()}|${question.subjectId}`]?.questionId === qid
586
- return { ok: true, question: this.questionView(data, question), thread, isToday }
693
+ const date=todayISO()
694
+ const isToday = data.daily[`${date}|${question.subjectId}`]?.questionId === qid
695
+ return { ok: true, question: this.questionView(data, question, { date, attemptId }), thread, isToday }
587
696
  }
588
697
 
589
698
  threadView(data, qid) {
@@ -599,71 +708,67 @@ export class DailyQuestionService {
599
708
  /* ------------------------------ 作答 ------------------------------ */
600
709
 
601
710
  async reveal({ qid, mode = 'submit', choice = '', attemptId }) {
602
- const data = await this.data()
603
- const question = data.questions[qid]
604
- if (!question) throw new Error('题目不存在')
605
- const isChoice = CHOICE_TYPES.has(question.type)
606
- let result = 'pending'
607
- let normalizedChoice = ''
608
- if (mode === 'submit') {
609
- if (!isChoice) throw new Error('主观题不支持提交选项')
610
- normalizedChoice = normalizeChoice(choice, question)
611
- const key = sortLetters(question.currentAnswer?.answerKey)
612
- result = key && normalizedChoice === key ? 'correct' : 'wrong'
613
- } else if (mode === 'giveup') {
614
- result = 'wrong'
615
- } else if (mode === 'done') {
616
- result = 'pending'
617
- } else {
618
- throw new Error(`未知的作答方式:${mode}`)
619
- }
620
-
621
- const stamp = nowISO()
622
- const attempt = {
623
- id: attemptId || randomUUID(),
624
- questionId: qid,
625
- at: stamp,
626
- type: question.type,
627
- mode,
628
- choice: normalizedChoice,
629
- result,
630
- revealedAt: stamp,
631
- judgedAt: mode === 'done' ? null : stamp,
632
- }
633
-
711
+ const id = attemptId || randomUUID()
634
712
  const duplicated = await this.store.update((draft) => {
635
- const target = draft.questions[qid]
636
- if (!target) throw new Error('题目不存在')
637
- const existing = draft.attempts.find((item) => item.id === attempt.id)
713
+ const question = draft.questions[qid]
714
+ if (!question) throw new Error('题目不存在')
715
+ const existing = draft.attempts.find((item) => item.id === id)
638
716
  if (existing) {
639
717
  if (existing.questionId !== qid) throw new Error('作答 ID 已用于其他题目')
718
+ if (existing.mode !== mode || (existing.choice || '') !== (mode === 'submit' ? sortLetters(choice) : '')) {
719
+ throw Object.assign(new Error('本轮已保存另一份作答,请查看已保存的结果'), { code: 'ATTEMPT_CONFLICT' })
720
+ }
640
721
  return true
641
722
  }
642
- draft.attempts.push(attempt)
643
- if (target.currentAnswer && !target.currentAnswer.firstShownAt) {
644
- target.currentAnswer.firstShownAt = stamp
723
+ let result = 'pending'
724
+ let normalizedChoice = ''
725
+ if (mode === 'submit') {
726
+ if (!CHOICE_TYPES.has(question.type)) throw new Error('主观题不支持提交选项')
727
+ if (!hasChoiceOptions(question.options)) throw new Error('选项不完整,请使用“作答完毕”查看答案')
728
+ normalizedChoice = normalizeChoice(choice, question)
729
+ const key = question.currentAnswer?.answerKey
730
+ if (validChoiceKey(question.type, key, question.options)) result = normalizedChoice === sortLetters(key) ? 'correct' : 'wrong'
731
+ } else if (mode === 'giveup') {
732
+ result = 'wrong'
733
+ } else if (mode !== 'done') {
734
+ throw new Error(`未知的作答方式:${mode}`)
735
+ }
736
+ const stamp = nowISO()
737
+ draft.attempts.push({
738
+ id, questionId: qid, at: stamp, practiceDate: todayISO(new Date(stamp)),
739
+ type: question.type, mode, choice: normalizedChoice, result,
740
+ revealedAt: stamp, judgedAt: result === 'pending' ? null : stamp,
741
+ })
742
+ if (question.currentAnswer && !question.currentAnswer.firstShownAt) {
743
+ question.currentAnswer.firstShownAt = stamp
645
744
  }
646
- target.updatedAt = stamp
745
+ question.updatedAt = stamp
647
746
  return false
648
747
  })
649
748
  const fresh = await this.data()
650
- return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question), duplicated }
749
+ if (!fresh.questions[qid]) throw new Error('题目已删除')
750
+ return { ok: true, question: this.questionView(fresh, fresh.questions[qid], { attemptId: id }), duplicated }
651
751
  }
652
752
 
653
- async judge({ qid, verdict }) {
753
+ async judge({ qid, verdict, attemptId }) {
654
754
  if (!['correct', 'wrong'].includes(verdict)) throw new Error('判定结果不合法')
655
- await this.store.update((draft) => {
656
- const attempts = draft.attempts.filter((attempt) => attempt.questionId === qid)
657
- const target = [...attempts].reverse().find((attempt) => attempt.result === 'pending' && !attempt.judgedAt)
658
- if (!target) throw new Error('当前没有待判定的作答')
755
+ if (typeof attemptId !== 'string' || !attemptId) throw new Error('缺少本次作答ID,请重新打开题目后判定')
756
+ const duplicated=await this.store.update((draft) => {
757
+ if (!draft.questions[qid]) throw new Error('题目不存在')
758
+ const target = draft.attempts.find((attempt) => attempt.id === attemptId && attempt.questionId === qid)
759
+ if (!target) throw new Error('本次作答不存在或不属于这道题')
760
+ if (target.result !== 'pending' || target.judgedAt) {
761
+ if (target.result === verdict && target.judgedAt) return true
762
+ throw new Error('本次作答已判定,不能覆盖或改判其他作答')
763
+ }
659
764
  target.result = verdict
660
765
  target.judgedAt = nowISO()
661
766
  const question = draft.questions[qid]
662
767
  if (question) question.updatedAt = nowISO()
663
- return draft
768
+ return false
664
769
  })
665
770
  const data = await this.data()
666
- return { ok: true, question: this.questionView(data, data.questions[qid]) }
771
+ return { ok: true, question: this.questionView(data, data.questions[qid], {attemptId}), duplicated }
667
772
  }
668
773
 
669
774
  async favorite({ qid, value }) {
@@ -701,7 +806,7 @@ export class DailyQuestionService {
701
806
  if (!Object.hasOwn(draft.questions, id)) throw new Error('部分题目已不存在,请刷新列表后重试')
702
807
  return draft.questions[id]
703
808
  })
704
- const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, draft.attempts)).length
809
+ const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, this.attemptsOf(draft,q.id))).length
705
810
  if (action === 'delete' && protectedCount && confirmProtected !== true) {
706
811
  return { requiresConfirmation: true, protectedCount }
707
812
  }
@@ -734,6 +839,10 @@ export class DailyQuestionService {
734
839
  /* ------------------------------ 答案修订 ------------------------------ */
735
840
 
736
841
  async revise({ qid, reason = '' }) {
842
+ return this.runTask('revise:'+qid, {kind:'revise',qid}, (signal) => this.createRevision({qid,reason}, signal))
843
+ }
844
+
845
+ async createRevision({ qid, reason }, signal) {
737
846
  const data = await this.data()
738
847
  const question = data.questions[qid]
739
848
  if (!question) throw new Error('题目不存在')
@@ -746,6 +855,7 @@ export class DailyQuestionService {
746
855
  question,
747
856
  currentAnswer: question.currentAnswer,
748
857
  reason,
858
+ signal,
749
859
  })
750
860
  const candidate = {
751
861
  candidateId: `c_${randomUUID().slice(0, 8)}`,
@@ -758,6 +868,7 @@ export class DailyQuestionService {
758
868
  }
759
869
  await this.store.update((draft) => {
760
870
  const target = draft.questions[qid]
871
+ signal.throwIfAborted()
761
872
  if (!target) throw new Error('题目不存在')
762
873
  target.pendingRevision = candidate
763
874
  return draft
@@ -820,6 +931,24 @@ export class DailyQuestionService {
820
931
  /* ------------------------------ 追问 ------------------------------ */
821
932
 
822
933
  async followup({ qid, message, clientMessageId }) {
934
+ const messageId = clientMessageId || randomUUID()
935
+ const key = JSON.stringify([qid, messageId])
936
+ if (this.inflightFollowups.has(key)) return this.inflightFollowups.get(key)
937
+ const executionId = randomUUID()
938
+ const task = this.runTask('followup:'+key, {kind:'followup',qid,clientMessageId:messageId},
939
+ (signal) => this.createFollowup({ qid, message, clientMessageId: messageId, executionId }, signal))
940
+ .catch(async (error) => {
941
+ await this.store.update((draft) => {
942
+ const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
943
+ if (target?.executionId === executionId && target.status === 'pending') target.status = 'failed'
944
+ }).catch(() => {})
945
+ throw error
946
+ }).finally(() => this.inflightFollowups.delete(key))
947
+ this.inflightFollowups.set(key, task)
948
+ return task
949
+ }
950
+
951
+ async createFollowup({ qid, message, clientMessageId, executionId }, signal) {
823
952
  const text = String(message || '').trim()
824
953
  if (!text) throw new Error('追问内容不能为空')
825
954
  if (text.length > 4000) throw new Error('追问内容过长,请精简后再发送')
@@ -838,16 +967,20 @@ export class DailyQuestionService {
838
967
  const messageId = failed ? failed.id : (clientMessageId || randomUUID())
839
968
 
840
969
  // 先落盘用户消息:即使回复失败,用户输入也不会丢,并且可以按同一 id 重试。
841
- await this.store.update((draft) => {
970
+ const queued = await this.store.update((draft) => {
971
+ signal.throwIfAborted()
842
972
  if (!draft.questions[qid]) throw new Error('题目已删除')
843
973
  if (!draft.threads[qid]) {
844
- draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: stamp }
974
+ draft.threads[qid] = { threadId: qid, questionId: qid, generation: randomUUID(), messages: [], updatedAt: stamp }
845
975
  }
846
976
  const thread = draft.threads[qid]
977
+ if (!thread.generation) thread.generation = randomUUID()
847
978
  const target = thread.messages.find((item) => item.id === messageId)
848
979
  if (target) {
980
+ if (target.status !== 'failed') return { duplicated: true, generation: thread.generation }
849
981
  target.text = text
850
982
  target.status = 'pending'
983
+ target.executionId = executionId
851
984
  } else {
852
985
  thread.messages.push({
853
986
  id: messageId,
@@ -857,17 +990,19 @@ export class DailyQuestionService {
857
990
  text,
858
991
  createdAt: stamp,
859
992
  status: 'pending',
993
+ executionId,
860
994
  })
861
995
  }
862
996
  thread.updatedAt = stamp
863
- return draft
997
+ return { duplicated: false, generation: thread.generation }
864
998
  })
999
+ if (queued.duplicated) return { ok: true, thread: this.threadView(await this.data(), qid), duplicated: true }
865
1000
 
866
1001
  const history = (await this.data()).threads[qid]?.messages || []
867
1002
  const setStatus = async (status) => {
868
1003
  await this.store.update((draft) => {
869
1004
  const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
870
- if (target) target.status = status
1005
+ if (target?.executionId === executionId) target.status = status
871
1006
  return draft
872
1007
  }).catch(() => {})
873
1008
  }
@@ -879,6 +1014,7 @@ export class DailyQuestionService {
879
1014
  answerDetail: question.currentAnswer,
880
1015
  history: history.filter((item) => item.id !== messageId),
881
1016
  message: text,
1017
+ signal,
882
1018
  })
883
1019
  const assistantMessage = {
884
1020
  id: randomUUID(),
@@ -890,11 +1026,12 @@ export class DailyQuestionService {
890
1026
  status: 'sent',
891
1027
  }
892
1028
  await this.store.update((draft) => {
1029
+ signal.throwIfAborted()
893
1030
  if (!draft.questions[qid]) throw new Error('题目已删除')
894
- if (!draft.threads[qid]) draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: nowISO() }
895
1031
  const thread = draft.threads[qid]
896
- const target = thread.messages.find((item) => item.id === messageId)
897
- if (target) target.status = 'sent'
1032
+ const target = thread?.messages.find((item) => item.id === messageId)
1033
+ if (!thread || thread.generation !== queued.generation || !target || target.executionId !== executionId || target.status !== 'pending') throw new Error('本次追问已清空或失效,已丢弃迟到的回复')
1034
+ target.status = 'sent'
898
1035
  thread.messages.push(assistantMessage)
899
1036
  thread.updatedAt = assistantMessage.createdAt
900
1037
  return draft
@@ -909,9 +1046,14 @@ export class DailyQuestionService {
909
1046
 
910
1047
  async clearThread({ qid }) {
911
1048
  await this.store.update((draft) => {
912
- if (draft.threads[qid]) draft.threads[qid].messages = []
1049
+ if (draft.threads[qid]) {
1050
+ draft.threads[qid].messages = []
1051
+ draft.threads[qid].generation = randomUUID()
1052
+ draft.threads[qid].updatedAt = nowISO()
1053
+ }
913
1054
  return draft
914
1055
  })
1056
+ await this.cancelTask({kind:'followup',qid})
915
1057
  const data = await this.data()
916
1058
  return { ok: true, thread: this.threadView(data, qid) }
917
1059
  }
@@ -925,7 +1067,7 @@ export class DailyQuestionService {
925
1067
  if (!base64) throw new Error('缺少 PDF 文件内容')
926
1068
  const bytes = Buffer.from(String(base64), 'base64')
927
1069
  if (bytes.length > 40 * 1024 * 1024) throw new Error('PDF 文件过大(上限 40MB)')
928
- const extracted = extractBankPdf(bytes, fileName || name || '')
1070
+ const extracted = await extractBankPdf(bytes, fileName || name || '')
929
1071
  content = extracted.text
930
1072
  warnings = extracted.warnings
931
1073
  } else {
@@ -966,12 +1108,13 @@ export class DailyQuestionService {
966
1108
  knowledge: item.knowledge,
967
1109
  difficulty: item.difficulty,
968
1110
  issues: item.issues,
1111
+ usable: isUsableBankItem(item),
969
1112
  answerStatus: item.answerStatus,
970
1113
  })),
971
1114
  stats: {
972
1115
  total: parsed.items.length,
973
1116
  withAnswer: parsed.items.filter((item) => item.answer).length,
974
- blocked: parsed.items.filter((item) => !item.stem).length,
1117
+ blocked: parsed.items.length - parsed.usable,
975
1118
  },
976
1119
  }
977
1120
  }
@@ -983,10 +1126,14 @@ export class DailyQuestionService {
983
1126
  }
984
1127
  }
985
1128
 
986
- async commitBank({ importId, name, subjectId }) {
1129
+ async commitBank({ importId, name, subjectId, excludedIndexes = [] }) {
987
1130
  const pending = this.imports.get(importId)
988
1131
  if (!pending) throw new Error('导入结果已过期,请重新选择文件导入')
989
- const usable = pending.items.filter((item) => item.stem && item.stem.length >= 4)
1132
+ if (!Array.isArray(excludedIndexes) || excludedIndexes.some((index) => !Number.isInteger(index) || index < 1 || index > pending.items.length)) {
1133
+ throw new Error('导入勾选有误,请重新选择题目')
1134
+ }
1135
+ const excluded = new Set(excludedIndexes)
1136
+ const usable = pending.items.filter((item) => !excluded.has(item.index) && isUsableBankItem(item))
990
1137
  if (!usable.length) throw new Error('没有可用的题目可以入库:请检查文件结构后重新导入')
991
1138
  const bankId = `bank_${randomUUID().slice(0, 8)}`
992
1139
  const bank = {
@@ -1010,11 +1157,13 @@ export class DailyQuestionService {
1010
1157
  answerStatus: item.answer ? 'unverified' : 'missing',
1011
1158
  })),
1012
1159
  }
1013
- await this.store.update((draft) => {
1014
- draft.banks.push(bank)
1015
- return draft
1016
- })
1017
1160
  this.imports.delete(importId)
1161
+ try {
1162
+ await this.store.update((draft) => { draft.banks.push(bank) })
1163
+ } catch (error) {
1164
+ this.imports.set(importId, pending)
1165
+ throw error
1166
+ }
1018
1167
  const data = await this.data()
1019
1168
  return { ok: true, bank: this.bankViews(data).find((item) => item.id === bankId), banks: this.bankViews(data), subjects: this.subjectViews(data) }
1020
1169
  }
@@ -1091,11 +1240,11 @@ export class DailyQuestionService {
1091
1240
  const kept = Object.values(data.questions).filter((question) => question.subjectId === subjectId).length
1092
1241
  const result = await this.saveSettings({
1093
1242
  subjects: remaining,
1094
- activeSubject: data.settings.activeSubject === subjectId ? (remaining[0] || null) : data.settings.activeSubject,
1243
+ activeSubject: data.settings.activeSubject === subjectId && !kept ? (remaining[0] || null) : data.settings.activeSubject,
1095
1244
  })
1096
1245
  return {
1097
1246
  ...result,
1098
- message: `科目已移出每日出题,历史数据保留:${kept} 道题目及其作答记录仍可在“全部”中查看。`,
1247
+ message: `科目已移出每日出题,历史数据保留:${kept} 道题目及其作答记录可通过“已停用科目”查看。`,
1099
1248
  }
1100
1249
  }
1101
1250
  }