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:
SpecialX
2026-06-24 12:01:54 +08:00
parent e4254f0f8e
commit 9783be58c0
9 changed files with 1713 additions and 0 deletions

413
scripts/seed-error-book.ts Normal file
View 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)
})