daily-question 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,7 +142,9 @@ export class DailyQuestionService {
89
142
  }
90
143
 
91
144
  async bootstrap() {
92
- const today = todayISO()
145
+ const now = new Date()
146
+ const today = todayISO(now)
147
+ const nextDayAt = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString()
93
148
  let data = await this.data()
94
149
  const options = yearOptions(today, data.examDates)
95
150
  // 旧设置可能落在窗口外(它的初试早已结束):自动收敛到可选的最早一年
@@ -112,12 +167,15 @@ export class DailyQuestionService {
112
167
  return {
113
168
  ok: true,
114
169
  dataFile: this.store.file,
170
+ pluginVersion: PLUGIN_VERSION,
115
171
  dataError: this.store.lastError,
116
172
  today,
117
- now: 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,15 +225,14 @@ 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
  }
@@ -180,12 +240,21 @@ export class DailyQuestionService {
180
240
  isWrong(question, attempts) {
181
241
  if (question.wrongBookRemoved) return false
182
242
  if (question.wrongBookAdded) return true
183
- const own = attempts.filter((attempt) => attempt.questionId === question.id)
184
- return own.some((attempt) => attempt.result === 'wrong')
243
+ return attempts.some((attempt) => attempt.questionId === question.id && attempt.result === 'wrong')
185
244
  }
186
245
 
187
246
  attemptsOf(data, qid) {
188
- 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)||[]
189
258
  }
190
259
 
191
260
  /* ------------------------------ 设置 ------------------------------ */
@@ -194,6 +263,9 @@ export class DailyQuestionService {
194
263
  const data = await this.store.update((draft) => {
195
264
  // 先落自定义科目,后面的 enabled 校验才能识别到它们。
196
265
  if (Array.isArray(patch.customSubjects)) {
266
+ if (patch.customSubjects.some((item) => item?.referenceSubjectId === 'independent' && !(typeof item.referenceExamName === 'string' && item.referenceExamName.trim()))) {
267
+ throw new Error('独立参考考试请填写准确的考试名称(院校自命题请包含院校名称)')
268
+ }
197
269
  draft.customSubjects = patch.customSubjects
198
270
  .filter((item) => item && typeof item.id === 'string' && typeof item.name === 'string')
199
271
  .map((item) => ({
@@ -203,6 +275,9 @@ export class DailyQuestionService {
203
275
  types: normalizeCustomTypes(item.types),
204
276
  outline: Array.isArray(item.outline) ? item.outline.filter((line) => typeof line === 'string').slice(0, 20) : [],
205
277
  guidance: typeof item.guidance === 'string' ? item.guidance : '',
278
+ referenceSubjectId: ['independent','auto'].includes(item.referenceSubjectId) ? item.referenceSubjectId : REFERENCE_EXAMS.find((exam) => exam.id === item.referenceSubjectId)?.id || '',
279
+ referenceExamName: typeof item.referenceExamName === 'string' ? item.referenceExamName.trim().slice(0,100) : '',
280
+ referenceExamCode: typeof item.referenceExamCode === 'string' ? item.referenceExamCode.trim().slice(0,20) : '',
206
281
  }))
207
282
  }
208
283
  if (patch.targetYear !== undefined) {
@@ -269,14 +344,15 @@ export class DailyQuestionService {
269
344
  return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
270
345
  }
271
346
 
272
- const task = this.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))
273
349
  this.inflightDaily.set(key, task)
274
350
  const result = await task
275
351
  const fresh = await this.data()
276
352
  return { ok: true, question: this.questionView(fresh, fresh.questions[result.questionId]), thread: this.threadView(fresh, result.questionId), created: result.created }
277
353
  }
278
354
 
279
- async createDaily(subject, date, key) {
355
+ async createDaily(subject, date, key, signal) {
280
356
  const data = await this.data()
281
357
  const source = data.settings.subjectSource[subject.id] || 'auto'
282
358
  const examInfo = resolveExamDate(data.settings.targetYear, data.examDates)
@@ -284,7 +360,7 @@ export class DailyQuestionService {
284
360
  try {
285
361
  if (source !== 'online') {
286
362
  const picked = this.pickFromBank(data, subject, date)
287
- if (picked) return await this.publishBankQuestion(picked, subject, date, key)
363
+ if (picked) return await this.publishBankQuestion(picked, subject, date, key, signal)
288
364
  if (source === 'bank') {
289
365
  throw new Error('题库中没有符合该科目要求的可用题目:请先导入题库,或把该科目的出题来源切换为在线出题')
290
366
  }
@@ -294,6 +370,8 @@ export class DailyQuestionService {
294
370
  ctx: this.ctx,
295
371
  subject,
296
372
  examInfo,
373
+ signal,
374
+ history:Object.values(data.questions).filter(question=>question.subjectId===subject.id),
297
375
  })
298
376
  const qid = `q_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`
299
377
  const stamp = nowISO()
@@ -305,7 +383,9 @@ export class DailyQuestionService {
305
383
  stem: generated.stem,
306
384
  options: generated.options,
307
385
  knowledge: generated.knowledge,
386
+ coverageTopic:generated.coverageTopic,
308
387
  difficulty: generated.difficulty,
388
+ examAlignment: generated.examAlignment,
309
389
  syllabusCheck: generated.syllabusCheck || '',
310
390
  source: {
311
391
  kind: 'online',
@@ -338,6 +418,9 @@ export class DailyQuestionService {
338
418
  }
339
419
 
340
420
  await this.store.update((draft) => {
421
+ signal?.throwIfAborted()
422
+ const history=Object.values(draft.questions).filter(question=>question.subjectId===subject.id)
423
+ if(findQuestionRepeat(generated,indexQuestionHistory(history)))throw new Error('题目与已发布题目重复,已阻止发布,请重试生成新题')
341
424
  draft.questions[qid] = record
342
425
  draft.daily[key] = { date, subjectId: subject.id, questionId: qid, status: 'ready', createdAt: stamp }
343
426
  delete draft.failures[subject.id]
@@ -345,6 +428,7 @@ export class DailyQuestionService {
345
428
  })
346
429
  return { questionId: qid, created: true }
347
430
  } catch (error) {
431
+ if (signal?.aborted) throw signal.reason
348
432
  const message = error instanceof QuestionGenerationError
349
433
  ? error.message
350
434
  : `在线出题失败:${error?.message || error}`
@@ -378,10 +462,11 @@ export class DailyQuestionService {
378
462
  return { ...chosen, repeated }
379
463
  }
380
464
 
381
- async publishBankQuestion(picked, subject, date, key) {
465
+ async publishBankQuestion(picked, subject, date, key, signal) {
382
466
  const { bank, item, qid, repeated } = picked
383
467
  const stamp = nowISO()
384
468
  await this.store.update((draft) => {
469
+ signal?.throwIfAborted()
385
470
  const existing = draft.questions[qid]
386
471
  if (existing) {
387
472
  existing.assignedCount = (existing.assignedCount || 1) + 1
@@ -454,10 +539,12 @@ export class DailyQuestionService {
454
539
 
455
540
  /* ------------------------------ 题目视图 ------------------------------ */
456
541
 
457
- questionView(data, question) {
542
+ questionView(data, question, { attemptId, date = todayISO() } = {}) {
458
543
  const attempts = this.attemptsOf(data, question.id)
459
- const 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,
@@ -552,9 +649,11 @@ export class DailyQuestionService {
552
649
  }
553
650
  }
554
651
 
555
- async listQuestions({ subjectId, kind = 'all' } = {}) {
652
+ async listQuestions({ subjectId, kind = 'all', page = 1, pageSize = 50, idsOnly = false } = {}) {
556
653
  const data = await this.data()
557
654
  if (!subjectId) throw new Error('缺少科目')
655
+ if(!['all','favorite','wrong','today'].includes(kind))throw new Error('未知题目栏目')
656
+ if(!Number.isInteger(page)||page<1||!Number.isInteger(pageSize)||pageSize<1||pageSize>100)throw new Error('分页参数无效,每页最多100道题')
558
657
  let questions = Object.values(data.questions).filter((question) => question.subjectId === subjectId)
559
658
  if (kind === 'favorite') questions = questions.filter((question) => question.favoriteAt)
560
659
  if (kind === 'wrong') {
@@ -568,12 +667,16 @@ export class DailyQuestionService {
568
667
  questions = questions.filter((question) => question.id === record?.questionId)
569
668
  }
570
669
  questions.sort((a, b) => String(b.assignedDate).localeCompare(String(a.assignedDate))
571
- || String(b.createdAt).localeCompare(String(a.createdAt)))
670
+ || String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id)))
671
+ const total=questions.length,totalPages=Math.max(1,Math.ceil(total/pageSize))
672
+ if(idsOnly===true)return {ok:true,kind,subjectId,total,ids:questions.map(q=>q.id)}
673
+ page=Math.min(page,totalPages)
572
674
  return {
573
675
  ok: true,
574
676
  kind,
575
677
  subjectId,
576
- 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)),
577
680
  }
578
681
  }
579
682
 
@@ -582,8 +685,9 @@ export class DailyQuestionService {
582
685
  const question = data.questions[qid]
583
686
  if (!question) throw new Error('题目不存在(可能已随题库删除而失效)')
584
687
  const thread = this.threadView(data, qid)
585
- const isToday = data.daily[`${todayISO()}|${question.subjectId}`]?.questionId === qid
586
- return { ok: true, question: this.questionView(data, question), thread, isToday }
688
+ const date=todayISO()
689
+ const isToday = data.daily[`${date}|${question.subjectId}`]?.questionId === qid
690
+ return { ok: true, question: this.questionView(data, question, {date}), thread, isToday }
587
691
  }
588
692
 
589
693
  threadView(data, qid) {
@@ -609,7 +713,9 @@ export class DailyQuestionService {
609
713
  if (!isChoice) throw new Error('主观题不支持提交选项')
610
714
  normalizedChoice = normalizeChoice(choice, question)
611
715
  const key = sortLetters(question.currentAnswer?.answerKey)
612
- 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'
613
719
  } else if (mode === 'giveup') {
614
720
  result = 'wrong'
615
721
  } else if (mode === 'done') {
@@ -623,12 +729,13 @@ export class DailyQuestionService {
623
729
  id: attemptId || randomUUID(),
624
730
  questionId: qid,
625
731
  at: stamp,
732
+ practiceDate:todayISO(new Date(stamp)),
626
733
  type: question.type,
627
734
  mode,
628
735
  choice: normalizedChoice,
629
736
  result,
630
737
  revealedAt: stamp,
631
- judgedAt: mode === 'done' ? null : stamp,
738
+ judgedAt: result === 'pending' ? null : stamp,
632
739
  }
633
740
 
634
741
  const duplicated = await this.store.update((draft) => {
@@ -647,23 +754,28 @@ export class DailyQuestionService {
647
754
  return false
648
755
  })
649
756
  const fresh = await this.data()
650
- return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question), duplicated }
757
+ return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question, {attemptId:attempt.id}), duplicated }
651
758
  }
652
759
 
653
- async judge({ qid, verdict }) {
760
+ async judge({ qid, verdict, attemptId }) {
654
761
  if (!['correct', 'wrong'].includes(verdict)) throw new Error('判定结果不合法')
655
- 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('当前没有待判定的作答')
762
+ if (typeof attemptId !== 'string' || !attemptId) throw new Error('缺少本次作答ID,请重新打开题目后判定')
763
+ const duplicated=await this.store.update((draft) => {
764
+ if (!draft.questions[qid]) throw new Error('题目不存在')
765
+ const target = draft.attempts.find((attempt) => attempt.id === attemptId && attempt.questionId === qid)
766
+ if (!target) throw new Error('本次作答不存在或不属于这道题')
767
+ if (target.result !== 'pending' || target.judgedAt) {
768
+ if (target.result === verdict && target.judgedAt) return true
769
+ throw new Error('本次作答已判定,不能覆盖或改判其他作答')
770
+ }
659
771
  target.result = verdict
660
772
  target.judgedAt = nowISO()
661
773
  const question = draft.questions[qid]
662
774
  if (question) question.updatedAt = nowISO()
663
- return draft
775
+ return false
664
776
  })
665
777
  const data = await this.data()
666
- return { ok: true, question: this.questionView(data, data.questions[qid]) }
778
+ return { ok: true, question: this.questionView(data, data.questions[qid], {attemptId}), duplicated }
667
779
  }
668
780
 
669
781
  async favorite({ qid, value }) {
@@ -701,7 +813,7 @@ export class DailyQuestionService {
701
813
  if (!Object.hasOwn(draft.questions, id)) throw new Error('部分题目已不存在,请刷新列表后重试')
702
814
  return draft.questions[id]
703
815
  })
704
- const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, draft.attempts)).length
816
+ const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, this.attemptsOf(draft,q.id))).length
705
817
  if (action === 'delete' && protectedCount && confirmProtected !== true) {
706
818
  return { requiresConfirmation: true, protectedCount }
707
819
  }
@@ -734,6 +846,10 @@ export class DailyQuestionService {
734
846
  /* ------------------------------ 答案修订 ------------------------------ */
735
847
 
736
848
  async revise({ qid, reason = '' }) {
849
+ return this.runTask('revise:'+qid, {kind:'revise',qid}, (signal) => this.createRevision({qid,reason}, signal))
850
+ }
851
+
852
+ async createRevision({ qid, reason }, signal) {
737
853
  const data = await this.data()
738
854
  const question = data.questions[qid]
739
855
  if (!question) throw new Error('题目不存在')
@@ -746,6 +862,7 @@ export class DailyQuestionService {
746
862
  question,
747
863
  currentAnswer: question.currentAnswer,
748
864
  reason,
865
+ signal,
749
866
  })
750
867
  const candidate = {
751
868
  candidateId: `c_${randomUUID().slice(0, 8)}`,
@@ -758,6 +875,7 @@ export class DailyQuestionService {
758
875
  }
759
876
  await this.store.update((draft) => {
760
877
  const target = draft.questions[qid]
878
+ signal.throwIfAborted()
761
879
  if (!target) throw new Error('题目不存在')
762
880
  target.pendingRevision = candidate
763
881
  return draft
@@ -820,6 +938,24 @@ export class DailyQuestionService {
820
938
  /* ------------------------------ 追问 ------------------------------ */
821
939
 
822
940
  async followup({ qid, message, clientMessageId }) {
941
+ const messageId = clientMessageId || randomUUID()
942
+ const key = JSON.stringify([qid, messageId])
943
+ if (this.inflightFollowups.has(key)) return this.inflightFollowups.get(key)
944
+ const executionId = randomUUID()
945
+ const task = this.runTask('followup:'+key, {kind:'followup',qid,clientMessageId:messageId},
946
+ (signal) => this.createFollowup({ qid, message, clientMessageId: messageId, executionId }, signal))
947
+ .catch(async (error) => {
948
+ await this.store.update((draft) => {
949
+ const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
950
+ if (target?.executionId === executionId && target.status === 'pending') target.status = 'failed'
951
+ }).catch(() => {})
952
+ throw error
953
+ }).finally(() => this.inflightFollowups.delete(key))
954
+ this.inflightFollowups.set(key, task)
955
+ return task
956
+ }
957
+
958
+ async createFollowup({ qid, message, clientMessageId, executionId }, signal) {
823
959
  const text = String(message || '').trim()
824
960
  if (!text) throw new Error('追问内容不能为空')
825
961
  if (text.length > 4000) throw new Error('追问内容过长,请精简后再发送')
@@ -838,16 +974,20 @@ export class DailyQuestionService {
838
974
  const messageId = failed ? failed.id : (clientMessageId || randomUUID())
839
975
 
840
976
  // 先落盘用户消息:即使回复失败,用户输入也不会丢,并且可以按同一 id 重试。
841
- await this.store.update((draft) => {
977
+ const queued = await this.store.update((draft) => {
978
+ signal.throwIfAborted()
842
979
  if (!draft.questions[qid]) throw new Error('题目已删除')
843
980
  if (!draft.threads[qid]) {
844
- draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: stamp }
981
+ draft.threads[qid] = { threadId: qid, questionId: qid, generation: randomUUID(), messages: [], updatedAt: stamp }
845
982
  }
846
983
  const thread = draft.threads[qid]
984
+ if (!thread.generation) thread.generation = randomUUID()
847
985
  const target = thread.messages.find((item) => item.id === messageId)
848
986
  if (target) {
987
+ if (target.status !== 'failed') return { duplicated: true, generation: thread.generation }
849
988
  target.text = text
850
989
  target.status = 'pending'
990
+ target.executionId = executionId
851
991
  } else {
852
992
  thread.messages.push({
853
993
  id: messageId,
@@ -857,17 +997,19 @@ export class DailyQuestionService {
857
997
  text,
858
998
  createdAt: stamp,
859
999
  status: 'pending',
1000
+ executionId,
860
1001
  })
861
1002
  }
862
1003
  thread.updatedAt = stamp
863
- return draft
1004
+ return { duplicated: false, generation: thread.generation }
864
1005
  })
1006
+ if (queued.duplicated) return { ok: true, thread: this.threadView(await this.data(), qid), duplicated: true }
865
1007
 
866
1008
  const history = (await this.data()).threads[qid]?.messages || []
867
1009
  const setStatus = async (status) => {
868
1010
  await this.store.update((draft) => {
869
1011
  const target = draft.threads[qid]?.messages.find((item) => item.id === messageId)
870
- if (target) target.status = status
1012
+ if (target?.executionId === executionId) target.status = status
871
1013
  return draft
872
1014
  }).catch(() => {})
873
1015
  }
@@ -879,6 +1021,7 @@ export class DailyQuestionService {
879
1021
  answerDetail: question.currentAnswer,
880
1022
  history: history.filter((item) => item.id !== messageId),
881
1023
  message: text,
1024
+ signal,
882
1025
  })
883
1026
  const assistantMessage = {
884
1027
  id: randomUUID(),
@@ -890,11 +1033,12 @@ export class DailyQuestionService {
890
1033
  status: 'sent',
891
1034
  }
892
1035
  await this.store.update((draft) => {
1036
+ signal.throwIfAborted()
893
1037
  if (!draft.questions[qid]) throw new Error('题目已删除')
894
- if (!draft.threads[qid]) draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: nowISO() }
895
1038
  const thread = draft.threads[qid]
896
- const target = thread.messages.find((item) => item.id === messageId)
897
- 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'
898
1042
  thread.messages.push(assistantMessage)
899
1043
  thread.updatedAt = assistantMessage.createdAt
900
1044
  return draft
@@ -909,9 +1053,14 @@ export class DailyQuestionService {
909
1053
 
910
1054
  async clearThread({ qid }) {
911
1055
  await this.store.update((draft) => {
912
- if (draft.threads[qid]) 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
+ }
913
1061
  return draft
914
1062
  })
1063
+ await this.cancelTask({kind:'followup',qid})
915
1064
  const data = await this.data()
916
1065
  return { ok: true, thread: this.threadView(data, qid) }
917
1066
  }
@@ -925,7 +1074,7 @@ export class DailyQuestionService {
925
1074
  if (!base64) throw new Error('缺少 PDF 文件内容')
926
1075
  const bytes = Buffer.from(String(base64), 'base64')
927
1076
  if (bytes.length > 40 * 1024 * 1024) throw new Error('PDF 文件过大(上限 40MB)')
928
- const extracted = extractBankPdf(bytes, fileName || name || '')
1077
+ const extracted = await extractBankPdf(bytes, fileName || name || '')
929
1078
  content = extracted.text
930
1079
  warnings = extracted.warnings
931
1080
  } else {
package/lib/subjects.js CHANGED
@@ -100,6 +100,22 @@ export const SUBJECT_CATALOG = [
100
100
 
101
101
  const CATALOG_BY_ID = new Map(SUBJECT_CATALOG.map((item) => [item.id, item]))
102
102
 
103
+ /** 参考考试独立于学习科目;不会因此在每日科目列表中新增408。 */
104
+ export const REFERENCE_EXAMS = [
105
+ ...SUBJECT_CATALOG.map(({id,name,code}) => ({id,name,code})),
106
+ {id:'cs408',name:'计算机学科专业基础',code:'408'},
107
+ ]
108
+
109
+ export function referenceExamFor(subject) {
110
+ if (!subject?.custom) return subject
111
+ if (!subject.referenceSubjectId || subject.referenceSubjectId === 'auto') return {id:'auto_'+subject.id,name:subject.name,code:subject.code || ''}
112
+ if (subject.referenceSubjectId === 'independent') {
113
+ const name = typeof subject.referenceExamName === 'string' ? subject.referenceExamName.trim() : ''
114
+ return name ? {id:'independent_'+subject.id,name,code:subject.referenceExamCode || ''} : undefined
115
+ }
116
+ return REFERENCE_EXAMS.find((exam) => exam.id === subject.referenceSubjectId)
117
+ }
118
+
103
119
  /** 全部可选科目 = 内置目录 + 用户自定义。 */
104
120
  export function allSubjects(customSubjects = []) {
105
121
  const extra = Array.isArray(customSubjects)
@@ -113,6 +129,9 @@ export function allSubjects(customSubjects = []) {
113
129
  outline: Array.isArray(item.outline) ? item.outline : [],
114
130
  guidance: typeof item.guidance === 'string' ? item.guidance : '',
115
131
  custom: true,
132
+ referenceSubjectId: typeof item.referenceSubjectId === 'string' ? item.referenceSubjectId : '',
133
+ referenceExamName: typeof item.referenceExamName === 'string' ? item.referenceExamName : '',
134
+ referenceExamCode: typeof item.referenceExamCode === 'string' ? item.referenceExamCode : '',
116
135
  }))
117
136
  : []
118
137
  return [...SUBJECT_CATALOG, ...extra]
package/lib/tutor.js CHANGED
@@ -7,7 +7,7 @@ const TUTOR_SYSTEM = `你是中国大陆硕士研究生招生考试的辅导老
7
7
  - 只讨论当前这道题:题干、选项、标准答案、解析以及由它们直接延伸的知识点。如果学生的提问明显超出这道题的范围,先说明它与本题的关系,再简短回答,不要把它当成跨题的通用聊天。
8
8
  - 用 Markdown 回答;数学公式用 LaTeX(行内 $...$,独立公式 $$...$$)。
9
9
  - 解释要一步步来,指出关键依据,不要只给结论。
10
- - 你给出的解释是对官方答案详解的补充说明,不能宣称修改了官方答案与解析。
10
+ - 你给出的解释是对当前题目参考答案的补充说明,不能宣称修改了已保存的答案与解析;模型生成、导入或修订的答案都不能称为“官方答案”。
11
11
  - 如果题目资料里标记答案尚未核验或缺少参考答案,必须如实说明,不要把未核验的内容说成确定结论。
12
12
  - 不要编造与题目无关的事实。`
13
13
 
@@ -29,12 +29,13 @@ export function describeQuestionContext(question, answerDetail) {
29
29
  for (const option of question.options) lines.push(`${option.key}. ${option.text}`)
30
30
  }
31
31
  lines.push('')
32
- if (answerDetail) {
33
- lines.push('--- 标准答案(已校验的正式版本)---')
32
+ if (answerDetail?.answer?.trim()) {
33
+ const status=answerDetail.verified===true?'已通过独立校验,仍非官方认证':'尚未独立核验,可能有误,请先核对'
34
+ lines.push(`--- 参考答案(${status})---`)
34
35
  lines.push(answerDetail.answer)
35
36
  lines.push('')
36
- lines.push('--- 解析(已校验的正式版本)---')
37
- lines.push(answerDetail.explanation)
37
+ lines.push(`--- 参考解析(${status})---`)
38
+ lines.push(answerDetail.explanation||'(尚未提供解析)')
38
39
  } else {
39
40
  lines.push('--- 答案状态 ---')
40
41
  lines.push('本题的参考答案与解析尚未通过独立校验或尚未生成,请如实说明,不要给出确定的“标准答案”。')