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:
619
src/modules/adaptive-practice/data-access.ts
Normal file
619
src/modules/adaptive-practice/data-access.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user