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
This commit is contained in:
413
scripts/seed-error-book.ts
Normal file
413
scripts/seed-error-book.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 错题本种子数据脚本
|
||||
*
|
||||
* 从现有的考试/作业提交中自动采集错题,并模拟部分复习记录。
|
||||
* 直接操作数据库,不依赖 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)
|
||||
})
|
||||
Reference in New Issue
Block a user