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/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.store.load()
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 today = todayISO()
93
- const options = yearOptions(today)
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: nowISO(),
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
- 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
- }
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
- const own = attempts.filter((attempt) => attempt.questionId === question.id)
183
- return own.some((attempt) => attempt.result === 'wrong')
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
- return data.attempts.filter((attempt) => attempt.questionId === qid)
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.createDaily(subject, date, key).finally(() => this.inflightDaily.delete(key))
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 fresh = candidates.filter((candidate) => candidate.lastAssignedDate !== date)
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 revealed = attempts.length > 0
460
- const last = attempts[attempts.length - 1] || null
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
- items: questions.map((question) => this.summaryView(data, question)),
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
- return { ok: true, question: this.questionView(data, question), thread }
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
- result = key && normalizedChoice === key ? 'correct' : 'wrong'
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: mode === 'done' ? null : stamp,
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 draft
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
- await this.store.update((draft) => {
655
- const attempts = draft.attempts.filter((attempt) => attempt.questionId === qid)
656
- const target = [...attempts].reverse().find((attempt) => attempt.result === 'pending' && !attempt.judgedAt)
657
- if (!target) throw new Error('当前没有待判定的作答')
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 draft
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 draft
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
- if (!draft.threads[qid]) draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: nowISO() }
1036
+ signal.throwIfAborted()
1037
+ if (!draft.questions[qid]) throw new Error('题目已删除')
851
1038
  const thread = draft.threads[qid]
852
- const target = thread.messages.find((item) => item.id === messageId)
853
- if (target) target.status = 'sent'
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]) draft.threads[qid].messages = []
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 {