daily-question 1.1.0 → 2.0.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/exam-dates.js CHANGED
@@ -253,16 +253,17 @@ export function computeCountdown(year, examInfo, today = todayISO()) {
253
253
  return base
254
254
  }
255
255
 
256
- const diff = calendarDiff(today, examInfo.date) || { years: 0, months: 0, days: 0 }
257
- base.calendar = diff
256
+ base.calendar = calendarDiff(today, examInfo.date)
257
+ const years = Math.floor(remaining / 365)
258
+ const months = Math.floor((remaining % 365) / 30)
259
+ const days = (remaining % 365) % 30
258
260
  base.lines = {
259
261
  primary: `距离${year}考研还剩${approxPrefix}${remaining}天`,
260
- secondary: `距离${year}考研还剩${approxPrefix}${diff.years}年${diff.months}个月${diff.days}天`,
262
+ secondary: remaining <= 30 ? '' : `${approxPrefix}${years ? `${years}年` : ''}${months ? `${months}个月` : ''}${days ? `${days}天` : ''}`,
261
263
  target: targetText,
262
264
  }
263
265
  if (remaining === 0) {
264
266
  base.lines.primary = `距离${year}考研还剩${approxPrefix}0天(今日开考)`
265
- base.lines.secondary = `距离${year}考研还剩${approxPrefix}0年0个月0天`
266
267
  }
267
268
  return base
268
269
  }
@@ -372,14 +373,15 @@ export function addDays(iso, days) {
372
373
  * 今年 12 月那场考试对应的入学年份是 year + 1。一旦这场初试结束,窗口整体
373
374
  * 后移一年:
374
375
  * 2026-11-04(初试前)→ 2027 / 2028 / 2029
375
- * 2026-12-20(初试后)→ 2028 / 2029 / 2030
376
+ * 2026-12-21(初试后)→ 2028 / 2029 / 2030
376
377
  */
377
- export function yearOptions(today = todayISO()) {
378
+ export function yearOptions(today = todayISO(), overrides = {}) {
378
379
  const parts = parseISODate(today)
379
380
  const year = parts?.year ?? new Date().getFullYear()
380
381
  const session = year + 1
381
- const sessionExamDate = resolveExamDate(session).date
382
- const finished = Boolean(sessionExamDate) && daysBetween(sessionExamDate, today) > 0
382
+ const exam = resolveExamDate(session, overrides)
383
+ const endDate = exam.endDate || exam.date
384
+ const finished = Boolean(endDate) && daysBetween(endDate, today) > 0
383
385
  const first = finished ? session + 1 : session
384
386
  return [first, first + 1, first + 2]
385
387
  }
package/lib/generate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { describeSubjectForPrompt, QUESTION_TYPE_LABEL, CHOICE_TYPES, subjectTypes } from './subjects.js'
1
+ import { describeSubjectForPrompt, QUESTION_TYPE_LABEL, CHOICE_TYPES } from './subjects.js'
2
2
  import { asString, asStringArray, generateText, parseJsonLoose, resolveModel } from './llm-json.js'
3
3
 
4
4
  /**
@@ -55,7 +55,7 @@ const VERIFIER_SYSTEM = `你是一位独立于命题人的审题与答案校验
55
55
  * @returns {{question: object, verification: object}}
56
56
  */
57
57
  export async function createVerifiedQuestion({ ctx, subject, examInfo, signal, preferredType }) {
58
- const allowed = subjectTypes(subject.id, [])
58
+ const allowed = subject.types
59
59
  const allowedLabels = allowed.map((type) => QUESTION_TYPE_LABEL[type] || type)
60
60
  const typeHint = preferredType && allowed.includes(preferredType) ? preferredType : allowed[Math.floor(Math.random() * allowed.length)]
61
61
  const trail = []
@@ -348,7 +348,7 @@ const REVISER_SYSTEM = `你是一位考研命题组的答案复核老师。你
348
348
  * 只有校验通过的候选才允许替换正式版本。
349
349
  */
350
350
  export async function reviseAnswer({ ctx, subject, examInfo, question, currentAnswer, reason, signal }) {
351
- const allowed = subjectTypes(question.subjectId, [])
351
+ const allowed = subject?.types || [question.type]
352
352
  let feedback = ''
353
353
  let lastReview = null
354
354
  let modelIdentity = ''
package/lib/routes.js CHANGED
@@ -69,6 +69,7 @@ export function registerRoutes(webServer, service, logger = console) {
69
69
  judge: (args) => service.judge(args),
70
70
  favorite: (args) => service.favorite(args),
71
71
  removeFromWrongBook: (args) => service.removeFromWrongBook(args),
72
+ bulkQuestions: (args) => service.bulkQuestions(args),
72
73
  revise: (args) => service.revise(args),
73
74
  confirmRevision: (args) => service.confirmRevision(args),
74
75
  discardRevision: (args) => service.discardRevision(args),
package/lib/service.js CHANGED
@@ -90,8 +90,8 @@ export class DailyQuestionService {
90
90
 
91
91
  async bootstrap() {
92
92
  const today = todayISO()
93
- const options = yearOptions(today)
94
93
  let data = await this.data()
94
+ const options = yearOptions(today, data.examDates)
95
95
  // 旧设置可能落在窗口外(它的初试早已结束):自动收敛到可选的最早一年
96
96
  if (!options.includes(data.settings.targetYear)) {
97
97
  await this.store.update((draft) => {
@@ -179,6 +179,7 @@ export class DailyQuestionService {
179
179
 
180
180
  isWrong(question, attempts) {
181
181
  if (question.wrongBookRemoved) return false
182
+ if (question.wrongBookAdded) return true
182
183
  const own = attempts.filter((attempt) => attempt.questionId === question.id)
183
184
  return own.some((attempt) => attempt.result === 'wrong')
184
185
  }
@@ -206,7 +207,7 @@ export class DailyQuestionService {
206
207
  }
207
208
  if (patch.targetYear !== undefined) {
208
209
  const year = Math.trunc(Number(patch.targetYear))
209
- const allowed = yearOptions()
210
+ const allowed = yearOptions(todayISO(), draft.examDates)
210
211
  if (!Number.isFinite(year) || !allowed.includes(year)) {
211
212
  throw new Error(`考研年份只能选 ${allowed[0]}、${allowed[1]}、${allowed[2]}(初试已结束的年份不再提供)`)
212
213
  }
@@ -371,8 +372,7 @@ export class DailyQuestionService {
371
372
  }
372
373
  if (!candidates.length) return null
373
374
  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
375
+ const pool = candidates.filter((candidate) => candidate.lastAssignedDate === candidates[0].lastAssignedDate)
376
376
  const chosen = pool[Math.floor(Math.random() * pool.length)]
377
377
  const repeated = Boolean(chosen.lastAssignedDate)
378
378
  return { ...chosen, repeated }
@@ -540,6 +540,7 @@ export class DailyQuestionService {
540
540
  type: question.type,
541
541
  typeLabel: QUESTION_TYPE_LABEL[question.type] || question.type,
542
542
  summary: summariseStem(question.stem),
543
+ stem: question.stem,
543
544
  assignedDate: question.assignedDate,
544
545
  attemptCount: attempts.length,
545
546
  favorite: Boolean(question.favoriteAt),
@@ -581,7 +582,8 @@ export class DailyQuestionService {
581
582
  const question = data.questions[qid]
582
583
  if (!question) throw new Error('题目不存在(可能已随题库删除而失效)')
583
584
  const thread = this.threadView(data, qid)
584
- return { ok: true, question: this.questionView(data, question), thread }
585
+ const isToday = data.daily[`${todayISO()}|${question.subjectId}`]?.questionId === qid
586
+ return { ok: true, question: this.questionView(data, question), thread, isToday }
585
587
  }
586
588
 
587
589
  threadView(data, qid) {
@@ -600,12 +602,6 @@ export class DailyQuestionService {
600
602
  const data = await this.data()
601
603
  const question = data.questions[qid]
602
604
  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
605
  const isChoice = CHOICE_TYPES.has(question.type)
610
606
  let result = 'pending'
611
607
  let normalizedChoice = ''
@@ -635,18 +631,23 @@ export class DailyQuestionService {
635
631
  judgedAt: mode === 'done' ? null : stamp,
636
632
  }
637
633
 
638
- await this.store.update((draft) => {
634
+ const duplicated = await this.store.update((draft) => {
639
635
  const target = draft.questions[qid]
640
636
  if (!target) throw new Error('题目不存在')
637
+ const existing = draft.attempts.find((item) => item.id === attempt.id)
638
+ if (existing) {
639
+ if (existing.questionId !== qid) throw new Error('作答 ID 已用于其他题目')
640
+ return true
641
+ }
641
642
  draft.attempts.push(attempt)
642
643
  if (target.currentAnswer && !target.currentAnswer.firstShownAt) {
643
644
  target.currentAnswer.firstShownAt = stamp
644
645
  }
645
646
  target.updatedAt = stamp
646
- return draft
647
+ return false
647
648
  })
648
649
  const fresh = await this.data()
649
- return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question) }
650
+ return { ok: true, question: this.questionView(fresh, fresh.questions[qid] || question), duplicated }
650
651
  }
651
652
 
652
653
  async judge({ qid, verdict }) {
@@ -689,6 +690,47 @@ export class DailyQuestionService {
689
690
  return { ok: true, question: this.questionView(data, data.questions[qid]), counts: this.counts(data) }
690
691
  }
691
692
 
693
+ async bulkQuestions({ qids, action, confirmProtected = false }) {
694
+ if (!Array.isArray(qids) || !qids.length || qids.some((id) => typeof id !== 'string' || !id)) {
695
+ throw new Error('请选择需要管理的题目')
696
+ }
697
+ if (!['delete', 'favorite', 'unfavorite', 'wrong', 'unwrong'].includes(action)) throw new Error('未知的批量操作')
698
+ const ids = new Set(qids)
699
+ const result = await this.store.update((draft) => {
700
+ const questions = [...ids].map((id) => {
701
+ if (!Object.hasOwn(draft.questions, id)) throw new Error('部分题目已不存在,请刷新列表后重试')
702
+ return draft.questions[id]
703
+ })
704
+ const protectedCount = questions.filter((q) => q.favoriteAt || this.isWrong(q, draft.attempts)).length
705
+ if (action === 'delete' && protectedCount && confirmProtected !== true) {
706
+ return { requiresConfirmation: true, protectedCount }
707
+ }
708
+ const stamp = nowISO()
709
+ for (const q of questions) {
710
+ if (action === 'delete') {
711
+ delete draft.questions[q.id]
712
+ delete draft.threads[q.id]
713
+ } else {
714
+ if (action === 'favorite') q.favoriteAt = stamp
715
+ if (action === 'unfavorite') q.favoriteAt = null
716
+ if (action === 'wrong' || action === 'unwrong') {
717
+ q.wrongBookAdded = action === 'wrong'
718
+ q.wrongBookRemoved = action === 'unwrong'
719
+ }
720
+ q.updatedAt = stamp
721
+ }
722
+ }
723
+ if (action === 'delete') {
724
+ draft.attempts = draft.attempts.filter((attempt) => !ids.has(attempt.questionId))
725
+ for (const [key, record] of Object.entries(draft.daily)) {
726
+ if (ids.has(record.questionId)) delete draft.daily[key]
727
+ }
728
+ }
729
+ return { affected: ids.size }
730
+ })
731
+ return { ok: true, ...result, counts: this.counts(await this.data()) }
732
+ }
733
+
692
734
  /* ------------------------------ 答案修订 ------------------------------ */
693
735
 
694
736
  async revise({ qid, reason = '' }) {
@@ -797,6 +839,7 @@ export class DailyQuestionService {
797
839
 
798
840
  // 先落盘用户消息:即使回复失败,用户输入也不会丢,并且可以按同一 id 重试。
799
841
  await this.store.update((draft) => {
842
+ if (!draft.questions[qid]) throw new Error('题目已删除')
800
843
  if (!draft.threads[qid]) {
801
844
  draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: stamp }
802
845
  }
@@ -847,6 +890,7 @@ export class DailyQuestionService {
847
890
  status: 'sent',
848
891
  }
849
892
  await this.store.update((draft) => {
893
+ if (!draft.questions[qid]) throw new Error('题目已删除')
850
894
  if (!draft.threads[qid]) draft.threads[qid] = { threadId: qid, questionId: qid, messages: [], updatedAt: nowISO() }
851
895
  const thread = draft.threads[qid]
852
896
  const target = thread.messages.find((item) => item.id === messageId)
package/lib/store.js CHANGED
@@ -147,18 +147,18 @@ export class Store {
147
147
  const draft = structuredClone(data)
148
148
  const result = await mutate(draft)
149
149
  draft.updatedAt = new Date().toISOString()
150
+ await this.write(draft)
150
151
  this.data = draft
151
- await this.write()
152
152
  return result
153
153
  }
154
154
  this.queue = this.queue.then(run, run)
155
155
  return this.queue
156
156
  }
157
157
 
158
- async write() {
158
+ async write(data = this.data) {
159
159
  await mkdir(this.dir, { recursive: true })
160
160
  const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
161
- await writeFile(tmp, JSON.stringify(this.data, null, 2), 'utf8')
161
+ await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8')
162
162
  await rename(tmp, this.file)
163
163
  }
164
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daily-question",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "考研每日一题 — DSH 插件:每科每天一道独立校验过的考研题,含倒计时、错题本、收藏与按题隔离的追问。",