feat(adaptive-practice): add new adaptive practice module

- Add adaptive practice module with data-access, schema, types, and components

- Provides personalized practice based on student performance and error patterns
This commit is contained in:
SpecialX
2026-06-24 12:02:04 +08:00
parent 9783be58c0
commit d7876c5854
16 changed files with 3691 additions and 0 deletions

View File

@@ -0,0 +1,619 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
practiceAnswers,
practiceSessions,
questions,
} from "@/shared/db/schema"
import { selectQuestionsForPractice } from "./data-access-strategy"
import type {
PracticeAnswerRecord,
PracticeAnswerStatus,
PracticeSessionDetail,
PracticeSessionSummary,
PracticeSourceMeta,
PracticeStats,
PracticeStatus,
PracticeType,
} from "./types"
// ---------------------------------------------------------------------------
// 行映射
// ---------------------------------------------------------------------------
function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessionSummary {
const answeredQuestions = row.answeredQuestions
const correctCount = row.correctCount
return {
id: row.id,
studentId: row.studentId,
subjectId: row.subjectId,
practiceType: row.practiceType as PracticeType,
status: row.status as PracticeStatus,
totalQuestions: row.totalQuestions,
answeredQuestions,
correctCount,
accuracy: answeredQuestions > 0 ? correctCount / answeredQuestions : 0,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
}
}
function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
question?: typeof questions.$inferSelect | null
}): PracticeAnswerRecord {
return {
id: row.id,
sessionId: row.sessionId,
questionId: row.questionId,
variantContent: row.variantContent,
isVariant: row.isVariant,
orderIndex: row.orderIndex,
status: row.status as PracticeAnswerStatus,
studentAnswer: row.studentAnswer,
isCorrect: row.isCorrect,
score: row.score,
maxScore: row.maxScore,
answeredAt: row.answeredAt,
question: row.question
? {
id: row.question.id,
content: row.question.content,
type: row.question.type,
difficulty: row.question.difficulty,
}
: null,
}
}
// ---------------------------------------------------------------------------
// 查询:练习会话列表
// ---------------------------------------------------------------------------
export const getPracticeSessions = cache(async (
studentId: string,
options?: {
status?: PracticeStatus
practiceType?: PracticeType
page?: number
pageSize?: number
},
): Promise<{ data: PracticeSessionSummary[]; total: number }> => {
const page = options?.page ?? 1
const pageSize = options?.pageSize ?? 20
const offset = (page - 1) * pageSize
const conditions = [eq(practiceSessions.studentId, studentId)]
if (options?.status) {
conditions.push(eq(practiceSessions.status, options.status))
}
if (options?.practiceType) {
conditions.push(eq(practiceSessions.practiceType, options.practiceType))
}
const whereClause = and(...conditions)
const [totalResult] = await db
.select({ value: count() })
.from(practiceSessions)
.where(whereClause)
const total = Number(totalResult?.value ?? 0)
const rows = await db
.select()
.from(practiceSessions)
.where(whereClause)
.orderBy(desc(practiceSessions.createdAt))
.limit(pageSize)
.offset(offset)
return {
data: rows.map(mapSessionRow),
total,
}
})
// ---------------------------------------------------------------------------
// 查询:练习会话详情(含答题记录)
// ---------------------------------------------------------------------------
export const getPracticeSessionById = cache(async (
sessionId: string,
studentId: string,
): Promise<PracticeSessionDetail | null> => {
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) return null
const answers = await db
.select()
.from(practiceAnswers)
.where(eq(practiceAnswers.sessionId, sessionId))
.orderBy(practiceAnswers.orderIndex)
// 批量查询题目内容
const questionIds = answers.map((a) => a.questionId)
const questionMap = new Map<string, typeof questions.$inferSelect>()
if (questionIds.length > 0) {
const questionRows = await db
.select()
.from(questions)
.where(inArray(questions.id, questionIds))
for (const q of questionRows) {
questionMap.set(q.id, q)
}
}
const mappedAnswers: PracticeAnswerRecord[] = answers.map((a) => {
const question = questionMap.get(a.questionId) ?? null
return mapAnswerRow({ ...a, question })
})
const summary = mapSessionRow(session)
return {
...summary,
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
answers: mappedAnswers,
}
})
// ---------------------------------------------------------------------------
// 查询:练习统计
// ---------------------------------------------------------------------------
export const getPracticeStats = cache(async (studentId: string): Promise<PracticeStats> => {
const rows = await db
.select({
practiceType: practiceSessions.practiceType,
status: practiceSessions.status,
totalQuestions: practiceSessions.totalQuestions,
answeredQuestions: practiceSessions.answeredQuestions,
correctCount: practiceSessions.correctCount,
})
.from(practiceSessions)
.where(eq(practiceSessions.studentId, studentId))
const totalSessions = rows.length
let completedSessions = 0
let totalQuestionsAnswered = 0
let totalCorrect = 0
const byTypeMap = new Map<string, { sessionCount: number; totalQuestions: number; correctCount: number }>()
for (const row of rows) {
if (row.status === "completed") {
completedSessions++
}
totalQuestionsAnswered += row.answeredQuestions
totalCorrect += row.correctCount
const stat = byTypeMap.get(row.practiceType) ?? { sessionCount: 0, totalQuestions: 0, correctCount: 0 }
stat.sessionCount++
stat.totalQuestions += row.totalQuestions
stat.correctCount += row.correctCount
byTypeMap.set(row.practiceType, stat)
}
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
practiceType: type as PracticeType,
sessionCount: stat.sessionCount,
totalQuestions: stat.totalQuestions,
correctCount: stat.correctCount,
accuracy: stat.totalQuestions > 0 ? stat.correctCount / stat.totalQuestions : 0,
}))
return {
totalSessions,
completedSessions,
totalQuestionsAnswered,
totalCorrect,
overallAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
byType,
}
})
// ---------------------------------------------------------------------------
// 写入:创建练习会话
// ---------------------------------------------------------------------------
/**
* 创建练习会话。
*
* 1. 根据练习类型调用出题策略选择题目
* 2. 创建会话记录
* 3. 创建答题记录(初始状态为 pending
*
* @returns 会话 ID 和选中的题目数量
*/
export async function createPracticeSession(
studentId: string,
input: {
practiceType: PracticeType
subjectId?: string
sourceMeta: PracticeSourceMeta
questionCount?: number
},
): Promise<{ sessionId: string; selectedCount: number }> {
const { practiceType, sourceMeta, questionCount = 10 } = input
// 调用出题策略选择题目
const selection = await selectQuestionsForPractice(
studentId,
practiceType,
sourceMeta,
questionCount,
)
if (selection.questionIds.length === 0) {
return { sessionId: "", selectedCount: 0 }
}
const sessionId = createId()
const now = new Date()
// 事务:创建会话 + 答题记录
await db.transaction(async (tx) => {
await tx.insert(practiceSessions).values({
id: sessionId,
studentId,
subjectId: input.subjectId ?? null,
practiceType,
sourceMeta: sourceMeta as unknown,
status: "in_progress",
totalQuestions: selection.questionIds.length,
answeredQuestions: 0,
correctCount: 0,
startedAt: now,
})
// 批量插入答题记录
const answerRows = selection.questionIds.map((questionId, index) => ({
id: createId(),
sessionId,
studentId,
questionId,
variantContent: selection.variants.get(questionId) ?? null,
isVariant: selection.variants.has(questionId),
orderIndex: index,
status: "pending" as const,
maxScore: 1,
}))
await tx.insert(practiceAnswers).values(answerRows)
})
return { sessionId, selectedCount: selection.questionIds.length }
}
// ---------------------------------------------------------------------------
// 写入:提交单题答案
// ---------------------------------------------------------------------------
/**
* 提交单题答案并自动判分。
*
* 自动判分逻辑:
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
* - 填空题暂不自动判分isCorrect = null
*
* @returns 是否判分成功
*/
export async function submitPracticeAnswer(
sessionId: string,
studentId: string,
answerId: string,
answer: unknown,
skip: boolean = false,
): Promise<{ isCorrect: boolean | null; score: number | null }> {
// 校验会话归属
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
}
if (session.status !== "in_progress") {
throw new Error("练习会话已结束")
}
// 查询答题记录
const answerRecord = await db.query.practiceAnswers.findFirst({
where: and(
eq(practiceAnswers.id, answerId),
eq(practiceAnswers.sessionId, sessionId),
),
})
if (!answerRecord) {
throw new Error("答题记录不存在")
}
if (answerRecord.status === "answered") {
throw new Error("此题已作答")
}
const now = new Date()
if (skip) {
// 跳过此题
await db
.update(practiceAnswers)
.set({
status: "skipped",
answeredAt: now,
})
.where(eq(practiceAnswers.id, answerId))
// 更新会话统计
await updateSessionStats(sessionId, 0, false)
return { isCorrect: null, score: null }
}
// 自动判分:查询题目内容并提取正确答案
const question = await db.query.questions.findFirst({
where: eq(questions.id, answerRecord.questionId),
})
if (!question) {
throw new Error("题目不存在")
}
// 如果是变式题,使用变式题内容
const contentToUse = answerRecord.variantContent ?? question.content
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
await db
.update(practiceAnswers)
.set({
status: "answered",
studentAnswer: answer,
isCorrect,
score,
answeredAt: now,
})
.where(eq(practiceAnswers.id, answerId))
// 更新会话统计
await updateSessionStats(
sessionId,
1,
isCorrect === true,
)
return { isCorrect, score }
}
// ---------------------------------------------------------------------------
// 写入:完成/放弃练习会话
// ---------------------------------------------------------------------------
export async function completePracticeSession(
sessionId: string,
studentId: string,
): Promise<void> {
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
}
if (session.status !== "in_progress") {
return
}
await db
.update(practiceSessions)
.set({
status: "completed",
completedAt: new Date(),
})
.where(eq(practiceSessions.id, sessionId))
}
export async function abandonPracticeSession(
sessionId: string,
studentId: string,
): Promise<void> {
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
}
if (session.status !== "in_progress") {
return
}
await db
.update(practiceSessions)
.set({
status: "abandoned",
completedAt: new Date(),
})
.where(eq(practiceSessions.id, sessionId))
}
// ---------------------------------------------------------------------------
// 内部辅助函数
// ---------------------------------------------------------------------------
/**
* 更新会话统计(已答题数、正确数)。
*/
async function updateSessionStats(
sessionId: string,
newlyAnswered: number,
newlyCorrect: boolean,
): Promise<void> {
const session = await db.query.practiceSessions.findFirst({
where: eq(practiceSessions.id, sessionId),
columns: {
answeredQuestions: true,
correctCount: true,
},
})
if (!session) return
await db
.update(practiceSessions)
.set({
answeredQuestions: session.answeredQuestions + newlyAnswered,
correctCount: session.correctCount + (newlyCorrect ? 1 : 0),
})
.where(eq(practiceSessions.id, sessionId))
}
/**
* 自动判分:比对学生答案与正确答案。
*
* 支持题型:
* - single_choice: 比对选中选项 ID
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
* - judgment: 比对布尔值
* - text: 不自动判分(返回 null
*
* @param questionType 题目类型
* @param content 题目内容(或变式题内容)
* @param studentAnswer 学生答案
* @returns 是否正确null 表示无法自动判分)
*/
function autoGradeAnswer(
questionType: string,
content: unknown,
studentAnswer: unknown,
): boolean | null {
if (questionType === "single_choice" || questionType === "multiple_choice") {
const correctIds = extractChoiceCorrectIds(content)
if (correctIds.length === 0) return null
const studentIds = normalizeAnswerToIds(studentAnswer)
if (studentIds.length === 0) return false
if (questionType === "single_choice") {
return studentIds.length === 1 && studentIds[0] === correctIds[0]
}
// multiple_choice: 集合比对
if (studentIds.length !== correctIds.length) return false
const correctSet = new Set(correctIds)
return studentIds.every((id) => correctSet.has(id))
}
if (questionType === "judgment") {
const correctAnswer = extractJudgmentCorrectAnswer(content)
if (correctAnswer === null) return null
const studentBool = normalizeAnswerToBool(studentAnswer)
if (studentBool === null) return null
return studentBool === correctAnswer
}
// text 题型不自动判分
return null
}
/**
* 从题目内容中提取选择题正确选项 ID 列表。
*/
function extractChoiceCorrectIds(content: unknown): string[] {
if (!isRecord(content)) return []
const options = content.options
if (!Array.isArray(options)) return []
return options
.filter((opt: unknown) => isRecord(opt) && opt.isCorrect === true)
.map((opt: unknown) => {
const record = opt as Record<string, unknown>
return typeof record.id === "string" ? record.id : ""
})
.filter((id: string) => id.length > 0)
}
/**
* 从题目内容中提取判断题正确答案。
*/
function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
if (!isRecord(content)) return null
const answer = content.answer
if (typeof answer === "boolean") return answer
if (typeof answer === "string") {
const lower = answer.toLowerCase()
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
}
return null
}
/**
* 将学生答案归一化为选项 ID 列表。
*/
function normalizeAnswerToIds(answer: unknown): string[] {
if (typeof answer === "string") return [answer]
if (Array.isArray(answer)) {
return answer.filter((v): v is string => typeof v === "string")
}
if (isRecord(answer)) {
const ids = answer.selectedIds
if (Array.isArray(ids)) {
return ids.filter((v): v is string => typeof v === "string")
}
if (typeof answer.id === "string") return [answer.id]
}
return []
}
/**
* 将学生答案归一化为布尔值。
*/
function normalizeAnswerToBool(answer: unknown): boolean | null {
if (typeof answer === "boolean") return answer
if (typeof answer === "string") {
const lower = answer.toLowerCase()
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
}
return null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}