Files
NextEdu/scripts/seed-error-book.ts
SpecialX 9783be58c0 feat(scripts): add diagnostic, seed, and test scripts
- Add add-ai-provider-visibility and add-missing-columns migration scripts

- Add clear-error-book, seed-error-book, diagnose-error-book scripts

- Add diagnose-tables and create-missing-tables scripts

- Add test-failing-modules and test-teacher-pages test scripts
2026-06-24 12:01:54 +08:00

414 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 错题本种子数据脚本
*
* 从现有的考试/作业提交中自动采集错题,并模拟部分复习记录。
* 直接操作数据库,不依赖 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<number> {
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<string, string[]>()
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<number> {
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<string, string[]>()
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<void> {
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<string, string[]>()
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<number>`count(*)` })
.from(errorBookItems)
const [totalReviews] = await db
.select({ count: sql<number>`count(*)` })
.from(errorBookReviews)
const [statusStats] = await db
.select({
newCount: sql<number>`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`,
learningCount: sql<number>`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`,
masteredCount: sql<number>`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)
})