- 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
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
/**
|
||
* 错题本种子数据脚本
|
||
*
|
||
* 从现有的考试/作业提交中自动采集错题,并模拟部分复习记录。
|
||
* 直接操作数据库,不依赖 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)
|
||
})
|