daily-question 2.1.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,9 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { findSubject, allSubjects, QUESTION_TYPE_LABEL, CHOICE_TYPES, subjectTypes, REFERENCE_EXAMS } 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
8
  import { createUpdateChecker, PLUGIN_VERSION } from './updates.js'
9
9
  import { withTimeout } from './async.js'
@@ -56,6 +56,8 @@ function normalizeChoice(value, question) {
56
56
  for (const letter of upper) {
57
57
  if (!keys.includes(letter)) throw new Error(`选项 ${letter} 不在本题选项中`)
58
58
  }
59
+ if (question.type !== 'multiple' && upper.length !== 1) throw new Error('本题只能选择一个选项')
60
+ if (new Set(upper).size !== upper.length) throw new Error('选项不能重复')
59
61
  return upper.split('').sort().join('')
60
62
  }
61
63
 
@@ -204,7 +206,7 @@ export class DailyQuestionService {
204
206
  enabled: enabled.has(subject.id),
205
207
  source: data.settings.subjectSource[subject.id] || 'auto',
206
208
  bankItemCount: usableBanks.reduce((total, bank) => total
207
- + 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),
208
210
  }
209
211
  })
210
212
  }
@@ -218,23 +220,22 @@ export class DailyQuestionService {
218
220
  subjectId: bank.subjectId || '',
219
221
  importedAt: bank.importedAt,
220
222
  itemCount: bank.items.length,
221
- usable: bank.items.filter((item) => item.stem).length,
223
+ usable: bank.items.filter(isUsableBankItem).length,
222
224
  pendingAnswers: bank.items.filter((item) => item.answerStatus !== 'verified').length,
223
225
  }))
224
226
  }
225
227
 
226
228
  counts(data) {
227
- const result = {}
228
- const enabled=new Set(data.settings.subjects)
229
- for(const id of enabled)result[id]={all:0,favorite:0,wrong:0}
229
+ const result = Object.create(null)
230
+ for (const id of data.settings.subjects) result[id] = { all: 0, favorite: 0, wrong: 0 }
230
231
  for(const question of Object.values(data.questions)) {
231
- if(!enabled.has(question.subjectId))continue
232
- const count=result[question.subjectId]
232
+ if (!question.subjectId) continue
233
+ const count = result[question.subjectId] ??= { all: 0, favorite: 0, wrong: 0 }
233
234
  count.all++
234
235
  if(question.favoriteAt)count.favorite++
235
236
  if(this.isWrong(question,this.attemptsOf(data,question.id)))count.wrong++
236
237
  }
237
- return result
238
+ return { ...result }
238
239
  }
239
240
 
240
241
  isWrong(question, attempts) {
@@ -419,6 +420,7 @@ export class DailyQuestionService {
419
420
 
420
421
  await this.store.update((draft) => {
421
422
  signal?.throwIfAborted()
423
+ if (!draft.settings.subjects.includes(subject.id)) throw new Error('该科目已停用')
422
424
  const history=Object.values(draft.questions).filter(question=>question.subjectId===subject.id)
423
425
  if(findQuestionRepeat(generated,indexQuestionHistory(history)))throw new Error('题目与已发布题目重复,已阻止发布,请重试生成新题')
424
426
  draft.questions[qid] = record
@@ -433,8 +435,7 @@ export class DailyQuestionService {
433
435
  ? error.message
434
436
  : `在线出题失败:${error?.message || error}`
435
437
  await this.store.update((draft) => {
436
- draft.failures[subject.id] = { at: nowISO(), message }
437
- return draft
438
+ if (draft.settings.subjects.includes(subject.id)) draft.failures[subject.id] = { at: nowISO(), message }
438
439
  }).catch(() => {})
439
440
  throw new Error(message)
440
441
  }
@@ -446,7 +447,7 @@ export class DailyQuestionService {
446
447
  for (const bank of data.banks) {
447
448
  if (bank.subjectId && bank.subjectId !== subject.id) continue
448
449
  for (const item of bank.items) {
449
- if (!item.stem) continue
450
+ if (!isUsableBankItem(item)) continue
450
451
  const qid = bankQuestionId(bank.id, item.id)
451
452
  const existing = data.questions[qid]
452
453
  if (existing && existing.subjectId !== subject.id) continue
@@ -467,6 +468,7 @@ export class DailyQuestionService {
467
468
  const stamp = nowISO()
468
469
  await this.store.update((draft) => {
469
470
  signal?.throwIfAborted()
471
+ if (!draft.settings.subjects.includes(subject.id)) throw new Error('该科目已停用')
470
472
  const existing = draft.questions[qid]
471
473
  if (existing) {
472
474
  existing.assignedCount = (existing.assignedCount || 1) + 1
@@ -546,6 +548,8 @@ export class DailyQuestionService {
546
548
  const revealed = currentAttempts.length > 0
547
549
  const last = (attemptId ? currentAttempts.find((attempt) => attempt.id === attemptId) : currentAttempts[currentAttempts.length - 1]) || null
548
550
  const answer = question.currentAnswer
551
+ const isChoice = CHOICE_TYPES.has(question.type)
552
+ const canSelect = isChoice && hasChoiceOptions(question.options)
549
553
  return {
550
554
  id: question.id,
551
555
  subjectId: question.subjectId,
@@ -553,7 +557,8 @@ export class DailyQuestionService {
553
557
  type: question.type,
554
558
  typeLabel: QUESTION_TYPE_LABEL[question.type] || question.type,
555
559
  /** 完形填空 / 阅读理解按选择题判分,前端据此渲染选项而不是“作答完毕”。 */
556
- isChoice: CHOICE_TYPES.has(question.type),
560
+ isChoice,
561
+ canSelect,
557
562
  stem: question.stem,
558
563
  options: question.options || [],
559
564
  knowledge: question.knowledge || [],
@@ -566,7 +571,7 @@ export class DailyQuestionService {
566
571
  paperEvidence: revealed ? question.examAlignment.paperEvidence : [],
567
572
  } : null,
568
573
  source: question.source,
569
- assignedDate: isToday?date:question.assignedDate,
574
+ assignedDate: isToday ? date : question.lastAssignedDate || question.assignedDate,
570
575
  practiceDate:isToday?date:null,
571
576
  todayAttemptCount:isToday?currentAttempts.length:0,
572
577
  createdAt: question.createdAt,
@@ -602,7 +607,7 @@ export class DailyQuestionService {
602
607
  ? {
603
608
  versionId: answer.versionId,
604
609
  answer: answer.answer,
605
- answerKey: answer.answerKey || '',
610
+ answerKey: canSelect && validChoiceKey(question.type, answer.answerKey, question.options) ? answer.answerKey : '',
606
611
  explanation: answer.explanation,
607
612
  verified: Boolean(answer.verified),
608
613
  basis: answer.basis,
@@ -638,7 +643,7 @@ export class DailyQuestionService {
638
643
  typeLabel: QUESTION_TYPE_LABEL[question.type] || question.type,
639
644
  summary: summariseStem(question.stem),
640
645
  stem: question.stem,
641
- assignedDate: question.assignedDate,
646
+ assignedDate: question.lastAssignedDate || question.assignedDate,
642
647
  attemptCount: attempts.length,
643
648
  favorite: Boolean(question.favoriteAt),
644
649
  wrongEver: this.isWrong(question, attempts),
@@ -666,7 +671,7 @@ export class DailyQuestionService {
666
671
  const record = data.daily[`${todayISO()}|${subjectId}`]
667
672
  questions = questions.filter((question) => question.id === record?.questionId)
668
673
  }
669
- questions.sort((a, b) => String(b.assignedDate).localeCompare(String(a.assignedDate))
674
+ questions.sort((a, b) => String(b.lastAssignedDate || b.assignedDate).localeCompare(String(a.lastAssignedDate || a.assignedDate))
670
675
  || String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id)))
671
676
  const total=questions.length,totalPages=Math.max(1,Math.ceil(total/pageSize))
672
677
  if(idsOnly===true)return {ok:true,kind,subjectId,total,ids:questions.map(q=>q.id)}
@@ -680,14 +685,14 @@ export class DailyQuestionService {
680
685
  }
681
686
  }
682
687
 
683
- async getQuestion({ qid }) {
688
+ async getQuestion({ qid, attemptId }) {
684
689
  const data = await this.data()
685
690
  const question = data.questions[qid]
686
691
  if (!question) throw new Error('题目不存在(可能已随题库删除而失效)')
687
692
  const thread = this.threadView(data, qid)
688
693
  const date=todayISO()
689
694
  const isToday = data.daily[`${date}|${question.subjectId}`]?.questionId === qid
690
- return { ok: true, question: this.questionView(data, question, {date}), thread, isToday }
695
+ return { ok: true, question: this.questionView(data, question, { date, attemptId }), thread, isToday }
691
696
  }
692
697
 
693
698
  threadView(data, qid) {
@@ -703,58 +708,46 @@ export class DailyQuestionService {
703
708
  /* ------------------------------ 作答 ------------------------------ */
704
709
 
705
710
  async reveal({ qid, mode = 'submit', choice = '', attemptId }) {
706
- const data = await this.data()
707
- const question = data.questions[qid]
708
- if (!question) throw new Error('题目不存在')
709
- const isChoice = CHOICE_TYPES.has(question.type)
710
- let result = 'pending'
711
- let normalizedChoice = ''
712
- if (mode === 'submit') {
713
- if (!isChoice) throw new Error('主观题不支持提交选项')
714
- normalizedChoice = normalizeChoice(choice, question)
715
- const key = sortLetters(question.currentAnswer?.answerKey)
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'
719
- } else if (mode === 'giveup') {
720
- result = 'wrong'
721
- } else if (mode === 'done') {
722
- result = 'pending'
723
- } else {
724
- throw new Error(`未知的作答方式:${mode}`)
725
- }
726
-
727
- const stamp = nowISO()
728
- const attempt = {
729
- id: attemptId || randomUUID(),
730
- questionId: qid,
731
- at: stamp,
732
- practiceDate:todayISO(new Date(stamp)),
733
- type: question.type,
734
- mode,
735
- choice: normalizedChoice,
736
- result,
737
- revealedAt: stamp,
738
- judgedAt: result === 'pending' ? null : stamp,
739
- }
740
-
711
+ const id = attemptId || randomUUID()
741
712
  const duplicated = await this.store.update((draft) => {
742
- const target = draft.questions[qid]
743
- if (!target) throw new Error('题目不存在')
744
- 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)
745
716
  if (existing) {
746
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
+ }
747
721
  return true
748
722
  }
749
- draft.attempts.push(attempt)
750
- if (target.currentAnswer && !target.currentAnswer.firstShownAt) {
751
- 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}`)
752
735
  }
753
- target.updatedAt = stamp
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
744
+ }
745
+ question.updatedAt = stamp
754
746
  return false
755
747
  })
756
748
  const fresh = await this.data()
757
- return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question, {attemptId:attempt.id}), duplicated }
749
+ if (!fresh.questions[qid]) throw new Error('题目已删除')
750
+ return { ok: true, question: this.questionView(fresh, fresh.questions[qid], { attemptId: id }), duplicated }
758
751
  }
759
752
 
760
753
  async judge({ qid, verdict, attemptId }) {
@@ -1115,12 +1108,13 @@ export class DailyQuestionService {
1115
1108
  knowledge: item.knowledge,
1116
1109
  difficulty: item.difficulty,
1117
1110
  issues: item.issues,
1111
+ usable: isUsableBankItem(item),
1118
1112
  answerStatus: item.answerStatus,
1119
1113
  })),
1120
1114
  stats: {
1121
1115
  total: parsed.items.length,
1122
1116
  withAnswer: parsed.items.filter((item) => item.answer).length,
1123
- blocked: parsed.items.filter((item) => !item.stem).length,
1117
+ blocked: parsed.items.length - parsed.usable,
1124
1118
  },
1125
1119
  }
1126
1120
  }
@@ -1132,10 +1126,14 @@ export class DailyQuestionService {
1132
1126
  }
1133
1127
  }
1134
1128
 
1135
- async commitBank({ importId, name, subjectId }) {
1129
+ async commitBank({ importId, name, subjectId, excludedIndexes = [] }) {
1136
1130
  const pending = this.imports.get(importId)
1137
1131
  if (!pending) throw new Error('导入结果已过期,请重新选择文件导入')
1138
- 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))
1139
1137
  if (!usable.length) throw new Error('没有可用的题目可以入库:请检查文件结构后重新导入')
1140
1138
  const bankId = `bank_${randomUUID().slice(0, 8)}`
1141
1139
  const bank = {
@@ -1159,11 +1157,13 @@ export class DailyQuestionService {
1159
1157
  answerStatus: item.answer ? 'unverified' : 'missing',
1160
1158
  })),
1161
1159
  }
1162
- await this.store.update((draft) => {
1163
- draft.banks.push(bank)
1164
- return draft
1165
- })
1166
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
+ }
1167
1167
  const data = await this.data()
1168
1168
  return { ok: true, bank: this.bankViews(data).find((item) => item.id === bankId), banks: this.bankViews(data), subjects: this.subjectViews(data) }
1169
1169
  }
@@ -1240,11 +1240,11 @@ export class DailyQuestionService {
1240
1240
  const kept = Object.values(data.questions).filter((question) => question.subjectId === subjectId).length
1241
1241
  const result = await this.saveSettings({
1242
1242
  subjects: remaining,
1243
- activeSubject: data.settings.activeSubject === subjectId ? (remaining[0] || null) : data.settings.activeSubject,
1243
+ activeSubject: data.settings.activeSubject === subjectId && !kept ? (remaining[0] || null) : data.settings.activeSubject,
1244
1244
  })
1245
1245
  return {
1246
1246
  ...result,
1247
- message: `科目已移出每日出题,历史数据保留:${kept} 道题目及其作答记录仍可在“全部”中查看。`,
1247
+ message: `科目已移出每日出题,历史数据保留:${kept} 道题目及其作答记录可通过“已停用科目”查看。`,
1248
1248
  }
1249
1249
  }
1250
1250
  }
package/lib/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
2
  import { homedir } from 'node:os'
3
3
  import path from 'node:path'
4
+ import { todayISO, yearOptions } from './exam-dates.js'
4
5
 
5
6
  /** Bump when the persisted shape changes incompatibly. */
6
7
  export const DATA_VERSION = 1
@@ -27,7 +28,7 @@ export function emptyData() {
27
28
  updatedAt: now,
28
29
  settings: {
29
30
  /** 目标考研年份(入学年份),例如 2028 表示 2028 年入学。 */
30
- targetYear: defaultTargetYear(),
31
+ targetYear: yearOptions()[0],
31
32
  /** 已启用科目 id,按用户顺序排列。 */
32
33
  subjects: [],
33
34
  /** 科目 id -> 'online' | 'bank' | 'auto' */
@@ -54,12 +55,6 @@ export function emptyData() {
54
55
  }
55
56
  }
56
57
 
57
- /** 默认目标年份:若 12 月考试尚未过去则取今年入学,否则取明年。 */
58
- function defaultTargetYear() {
59
- const now = new Date()
60
- return now.getMonth() >= 11 ? now.getFullYear() + 2 : now.getFullYear() + 1
61
- }
62
-
63
58
  function isPlainObject(value) {
64
59
  return value !== null && typeof value === 'object' && !Array.isArray(value)
65
60
  }
@@ -85,7 +80,7 @@ function normalize(raw) {
85
80
  data.settings[key] = base.settings[key]
86
81
  }
87
82
  }
88
- if (!Number.isFinite(data.settings.targetYear)) data.settings.targetYear = base.settings.targetYear
83
+ if (!Number.isFinite(raw.settings?.targetYear)) data.settings.targetYear = yearOptions(todayISO(), data.examDates)[0]
89
84
  data.version = DATA_VERSION
90
85
  return data
91
86
  }
package/lib/subjects.js CHANGED
@@ -22,6 +22,18 @@ export const QUESTION_TYPE_LABEL = {
22
22
  /** 选择题类题型(判分依据唯一选项)。完形填空与阅读理解同样按四选一判分。 */
23
23
  export const CHOICE_TYPES = new Set(['single', 'multiple', 'cloze', 'reading'])
24
24
 
25
+ export function hasChoiceOptions(options) {
26
+ return Array.isArray(options) && options.length >= 2
27
+ && options.every((option) => /^[A-H]$/.test(option?.key) && typeof option.text === 'string' && option.text.trim())
28
+ && new Set(options.map((option) => option.key)).size === options.length
29
+ }
30
+
31
+ export function validChoiceKey(type, key, options) {
32
+ return typeof key === 'string' && /^[A-H]+$/.test(key) && (type === 'multiple' || key.length === 1)
33
+ && new Set(key).size === key.length
34
+ && [...key].every((letter) => options?.some((option) => option?.key === letter))
35
+ }
36
+
25
37
  export const SUBJECT_CATALOG = [
26
38
  {
27
39
  id: 'politics',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daily-question",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "考研每日一题 — DSH 插件:每科每天一道独立校验过的考研题,含倒计时、错题本、收藏与按题隔离的追问。",