/** * 错题本种子数据脚本 * * 从现有的考试/作业提交中自动采集错题,并模拟部分复习记录。 * 直接操作数据库,不依赖 data-access.ts(避免 server-only 包冲突)。 * 用法:npx tsx scripts/seed-error-book.ts */ import "dotenv/config" import { db } from "../src/shared/db" import { examSubmissions, homeworkSubmissions, submissionAnswers, homeworkAnswers, examQuestions, homeworkAssignmentQuestions, errorBookItems, errorBookReviews, questionsToKnowledgePoints, exams, homeworkAssignments, } from "../src/shared/db/schema" import { eq, and, inArray, sql } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" // SM-2 算法常量(与 sm2-algorithm.ts 保持一致) const REVIEW_INTERVALS = { again: { interval: 1, masteryDelta: -1, streakDelta: -999 }, hard: { interval: 2, masteryDelta: 0, streakDelta: 0 }, good: { interval: 4, masteryDelta: 1, streakDelta: 1 }, easy: { interval: 7, masteryDelta: 2, streakDelta: 1 }, } as const const INTERVAL_MULTIPLIERS = { again: 1, hard: 1.2, good: 1.5, easy: 2, } as const type ReviewResult = keyof typeof REVIEW_INTERVALS function calculateNewInterval(currentInterval: number, result: ReviewResult, reviewCount: number): number { const base = REVIEW_INTERVALS[result] if (result === "again") return 1 if (reviewCount === 0) return base.interval const multiplier = INTERVAL_MULTIPLIERS[result] return Math.max(base.interval, Math.round(currentInterval * multiplier)) } function calculateNewMastery(currentMastery: number, result: ReviewResult, correctStreak: number): number { const delta = REVIEW_INTERVALS[result].masteryDelta const newMastery = Math.max(0, Math.min(5, currentMastery + delta)) if (correctStreak >= 3) return Math.max(newMastery, 5) return newMastery } function deriveStatus(masteryLevel: number, correctStreak: number): "new" | "learning" | "mastered" { if (masteryLevel >= 5 || correctStreak >= 3) return "mastered" if (masteryLevel >= 1) return "learning" return "new" } function calculateNewCorrectStreak(currentStreak: number, result: ReviewResult): number { const streakDelta = REVIEW_INTERVALS[result].streakDelta if (streakDelta < 0) return 0 return currentStreak + streakDelta } async function collectFromExamSubmission(submissionId: string, studentId: string): Promise { const submission = await db.query.examSubmissions.findFirst({ where: and(eq(examSubmissions.id, submissionId), eq(examSubmissions.studentId, studentId)), }) if (!submission) return 0 // 查询考试以获取 subjectId const exam = await db.query.exams.findFirst({ where: eq(exams.id, submission.examId), }) const examSubjectId = exam?.subjectId ?? null const answers = await db .select({ questionId: submissionAnswers.questionId, answerContent: submissionAnswers.answerContent, score: submissionAnswers.score, feedback: submissionAnswers.feedback, }) .from(submissionAnswers) .where(eq(submissionAnswers.submissionId, submissionId)) const questionIds = answers.map((a) => a.questionId) const examQuestionScores = await db .select({ questionId: examQuestions.questionId, maxScore: examQuestions.score }) .from(examQuestions) .where(and(eq(examQuestions.examId, submission.examId), inArray(examQuestions.questionId, questionIds))) const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0])) const wrongAnswers = answers.filter((a) => { const max = maxScoreMap.get(a.questionId) ?? 0 return (a.score ?? 0) < max }) if (wrongAnswers.length === 0) return 0 // 去重 const existing = await db .select({ questionId: errorBookItems.questionId }) .from(errorBookItems) .where( and( eq(errorBookItems.studentId, studentId), inArray(errorBookItems.questionId, wrongAnswers.map((a) => a.questionId)) ) ) const existingSet = new Set(existing.map((e) => e.questionId)) // 查询知识点 const kpRows = await db .select({ questionId: questionsToKnowledgePoints.questionId, knowledgePointId: questionsToKnowledgePoints.knowledgePointId, }) .from(questionsToKnowledgePoints) .where(inArray(questionsToKnowledgePoints.questionId, wrongAnswers.map((a) => a.questionId))) const kpMap = new Map() for (const kp of kpRows) { const list = kpMap.get(kp.questionId) ?? [] list.push(kp.knowledgePointId) kpMap.set(kp.questionId, list) } const now = new Date() const toInsert = wrongAnswers .filter((a) => !existingSet.has(a.questionId)) .map((a) => ({ id: createId(), studentId, questionId: a.questionId, sourceType: "exam" as const, sourceId: submissionId, studentAnswer: a.answerContent, correctAnswer: null, subjectId: examSubjectId, // 从考试中获取学科 knowledgePointIds: kpMap.get(a.questionId) ?? null, status: "new" as const, masteryLevel: 0, nextReviewAt: now, reviewInterval: 1, reviewCount: 0, correctStreak: 0, note: a.feedback ?? null, errorTags: null, })) if (toInsert.length > 0) { await db.insert(errorBookItems).values(toInsert) } return toInsert.length } async function collectFromHomeworkSubmission(submissionId: string, studentId: string): Promise { const submission = await db.query.homeworkSubmissions.findFirst({ where: and(eq(homeworkSubmissions.id, submissionId), eq(homeworkSubmissions.studentId, studentId)), }) if (!submission) return 0 // 查询作业以获取 subjectId。 // homeworkAssignments 表本身没有 subjectId 字段, // 若作业派生自试卷(sourceExamId 不为空),则从源试卷的 subjectId 获取。 const assignment = await db.query.homeworkAssignments.findFirst({ where: eq(homeworkAssignments.id, submission.assignmentId), }) let hwSubjectId: string | null = null if (assignment?.sourceExamId) { const sourceExam = await db.query.exams.findFirst({ where: eq(exams.id, assignment.sourceExamId), }) hwSubjectId = sourceExam?.subjectId ?? null } const answers = await db .select({ questionId: homeworkAnswers.questionId, answerContent: homeworkAnswers.answerContent, score: homeworkAnswers.score, feedback: homeworkAnswers.feedback, }) .from(homeworkAnswers) .where(eq(homeworkAnswers.submissionId, submissionId)) const questionIds = answers.map((a) => a.questionId) const hwQuestionScores = await db .select({ questionId: homeworkAssignmentQuestions.questionId, maxScore: homeworkAssignmentQuestions.score }) .from(homeworkAssignmentQuestions) .where( and( eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId), inArray(homeworkAssignmentQuestions.questionId, questionIds) ) ) const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0])) const wrongAnswers = answers.filter((a) => { const max = maxScoreMap.get(a.questionId) ?? 0 return (a.score ?? 0) < max }) if (wrongAnswers.length === 0) return 0 const existing = await db .select({ questionId: errorBookItems.questionId }) .from(errorBookItems) .where( and( eq(errorBookItems.studentId, studentId), inArray(errorBookItems.questionId, wrongAnswers.map((a) => a.questionId)) ) ) const existingSet = new Set(existing.map((e) => e.questionId)) const kpRows = await db .select({ questionId: questionsToKnowledgePoints.questionId, knowledgePointId: questionsToKnowledgePoints.knowledgePointId, }) .from(questionsToKnowledgePoints) .where(inArray(questionsToKnowledgePoints.questionId, wrongAnswers.map((a) => a.questionId))) const kpMap = new Map() for (const kp of kpRows) { const list = kpMap.get(kp.questionId) ?? [] list.push(kp.knowledgePointId) kpMap.set(kp.questionId, list) } const now = new Date() const toInsert = wrongAnswers .filter((a) => !existingSet.has(a.questionId)) .map((a) => ({ id: createId(), studentId, questionId: a.questionId, sourceType: "homework" as const, sourceId: submissionId, studentAnswer: a.answerContent, correctAnswer: null, subjectId: hwSubjectId, // 从作业中获取学科 knowledgePointIds: kpMap.get(a.questionId) ?? null, status: "new" as const, masteryLevel: 0, nextReviewAt: now, reviewInterval: 1, reviewCount: 0, correctStreak: 0, note: a.feedback ?? null, errorTags: null, })) if (toInsert.length > 0) { await db.insert(errorBookItems).values(toInsert) } return toInsert.length } async function recordReview(itemId: string, studentId: string, result: ReviewResult): Promise { const item = await db.query.errorBookItems.findFirst({ where: and(eq(errorBookItems.id, itemId), eq(errorBookItems.studentId, studentId)), }) if (!item) throw new Error("错题不存在") const newStreak = calculateNewCorrectStreak(item.correctStreak, result) const newInterval = calculateNewInterval(item.reviewInterval, result, item.reviewCount) const newMastery = calculateNewMastery(item.masteryLevel, result, newStreak) const newStatus = deriveStatus(newMastery, newStreak) const nextReviewAt = newStatus === "mastered" ? null : new Date(Date.now() + newInterval * 86400_000) await db.insert(errorBookReviews).values({ id: createId(), itemId, studentId, reviewResult: result, reviewedAt: new Date(), newInterval, newMasteryLevel: newMastery, }) await db .update(errorBookItems) .set({ reviewInterval: newInterval, reviewCount: item.reviewCount + 1, correctStreak: newStreak, masteryLevel: newMastery, status: newStatus, nextReviewAt, updatedAt: new Date(), }) .where(eq(errorBookItems.id, itemId)) } async function seedErrorBook() { console.log("🌱 开始生成错题本种子数据...") // 1. 查询所有已批改的考试提交 const gradedExamSubmissions = await db .select({ id: examSubmissions.id, studentId: examSubmissions.studentId, examId: examSubmissions.examId, }) .from(examSubmissions) .where(eq(examSubmissions.status, "graded")) console.log(`📋 找到 ${gradedExamSubmissions.length} 份已批改考试提交`) // 2. 从考试提交中自动采集错题 let examCollected = 0 for (const sub of gradedExamSubmissions) { try { const count = await collectFromExamSubmission(sub.id, sub.studentId) examCollected += count } catch (e) { console.warn(`⚠️ 考试提交 ${sub.id} 采集失败:`, (e as Error).message) } } console.log(`✓ 从考试提交中采集 ${examCollected} 条错题`) // 3. 查询所有已批改的作业提交 const gradedHomeworkSubmissions = await db .select({ id: homeworkSubmissions.id, studentId: homeworkSubmissions.studentId, }) .from(homeworkSubmissions) .where(eq(homeworkSubmissions.status, "graded")) console.log(`📋 找到 ${gradedHomeworkSubmissions.length} 份已批改作业提交`) // 4. 从作业提交中自动采集错题 let homeworkCollected = 0 for (const sub of gradedHomeworkSubmissions) { try { const count = await collectFromHomeworkSubmission(sub.id, sub.studentId) homeworkCollected += count } catch (e) { console.warn(`⚠️ 作业提交 ${sub.id} 采集失败:`, (e as Error).message) } } console.log(`✓ 从作业提交中采集 ${homeworkCollected} 条错题`) // 5. 模拟复习记录:为每个学生的前 3 条错题添加复习 const allErrorItems = await db .select({ id: errorBookItems.id, studentId: errorBookItems.studentId, }) .from(errorBookItems) .where(eq(errorBookItems.status, "new")) const byStudent = new Map() for (const item of allErrorItems) { const list = byStudent.get(item.studentId) ?? [] list.push(item.id) byStudent.set(item.studentId, list) } let reviewCount = 0 const reviewResults: ReviewResult[] = ["again", "hard", "good"] for (const [studentId, itemIds] of byStudent) { // 为前 3 条错题添加复习记录 for (let i = 0; i < Math.min(3, itemIds.length); i++) { const itemId = itemIds[i] const result = reviewResults[i % 3] try { await recordReview(itemId, studentId, result) reviewCount++ } catch (e) { console.warn(`⚠️ 复习记录 ${itemId} 创建失败:`, (e as Error).message) } } } console.log(`✓ 创建 ${reviewCount} 条复习记录`) // 6. 统计结果 const [totalItems] = await db .select({ count: sql`count(*)` }) .from(errorBookItems) const [totalReviews] = await db .select({ count: sql`count(*)` }) .from(errorBookReviews) const [statusStats] = await db .select({ newCount: sql`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`, learningCount: sql`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`, masteredCount: sql`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`, }) .from(errorBookItems) console.log("\n📊 错题本数据统计:") console.log(` 总错题数: ${totalItems.count}`) console.log(` 总复习记录: ${totalReviews.count}`) console.log(` 状态分布: new=${statusStats.newCount}, learning=${statusStats.learningCount}, mastered=${statusStats.masteredCount}`) console.log("\n✅ 错题本种子数据生成完成") process.exit(0) } seedErrorBook().catch((e) => { console.error("❌ 错题本种子数据生成失败:", e) process.exit(1) })