feat(exams,homework): add error collection data-access for error book integration

- Add data-access-error-collection in exams module for collecting wrong exam answers

- Add data-access-error-collection in homework module for collecting wrong homework answers

- Update exams actions, exam-ai-generator, data-access, and types

- Update homework actions and data-access-write
This commit is contained in:
SpecialX
2026-06-24 12:03:03 +08:00
parent 0cee93676b
commit f0f713ff33
8 changed files with 623 additions and 25 deletions

View File

@@ -8,6 +8,8 @@ import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError, safeJsonParse, safeParseDate } from "@/shared/lib/action-utils"
import { trackExamEvent } from "@/shared/lib/track-event"
import { collectFromHomeworkSubmission } from "@/modules/error-book/data-access-collection"
import { updateMasteryFromHomeworkSubmission } from "@/modules/diagnostic/data-access"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
import {
@@ -32,6 +34,26 @@ const parseStudentIds = (raw: string): string[] => {
.filter((s) => s.length > 0)
}
/**
* 批改后处理:自动采集错题 + 更新知识点掌握度。
*
* 使用 Promise.allSettled 并行执行,确保一个失败不影响另一个。
* 错误被记录但不抛出,不影响批改操作的成功返回。
*/
async function runPostGradingHooks(submissionId: string, studentId: string): Promise<void> {
const [errorBookResult, masteryResult] = await Promise.allSettled([
collectFromHomeworkSubmission(submissionId, studentId),
updateMasteryFromHomeworkSubmission(submissionId),
])
if (errorBookResult.status === "rejected") {
console.error(`[post-grading] 错题采集失败 submission=${submissionId}:`, errorBookResult.reason)
}
if (masteryResult.status === "rejected") {
console.error(`[post-grading] 掌握度更新失败 submission=${submissionId}:`, masteryResult.reason)
}
}
export async function createHomeworkAssignmentAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -245,6 +267,11 @@ export async function submitHomeworkAction(
// V3-2: 即时自动批改回写
const { isFullyAutoGraded, totalScore } = await markHomeworkSubmitted(submissionId, isLate)
// 批改完成后自动采集错题 + 更新掌握度(仅当全部自动批改完成时)
if (isFullyAutoGraded) {
await runPostGradingHooks(submissionId, ctx.userId)
}
revalidatePath("/teacher/homework/submissions")
revalidatePath("/student/learning/assignments")
@@ -293,16 +320,14 @@ export async function gradeHomeworkSubmissionAction(
const { submissionId, answers } = parsed.data
// 权限二次校验:非管理员仅可批改自己创建的作业提交
// 管理员dataScope.type === "all")可批改所有提交
if (ctx.dataScope.type !== "all") {
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
if (!submissionForGrading) {
return { success: false, message: "Submission not found" }
}
if (submissionForGrading.creatorId !== ctx.userId) {
return { success: false, message: "You can only grade submissions for your own assignments" }
}
// 权限二次校验 + 获取 studentId用于批改后处理
// 非管理员仅可批改自己创建的作业提交;管理员dataScope.type === "all")可批改所有提交
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
if (!submissionForGrading) {
return { success: false, message: "Submission not found" }
}
if (ctx.dataScope.type !== "all" && submissionForGrading.creatorId !== ctx.userId) {
return { success: false, message: "You can only grade submissions for your own assignments" }
}
await gradeHomeworkAnswers(
@@ -314,6 +339,9 @@ export async function gradeHomeworkSubmissionAction(
}))
)
// 批改完成后自动采集错题 + 更新掌握度
await runPostGradingHooks(submissionId, submissionForGrading.studentId)
revalidatePath("/teacher/homework/submissions")
// V3-4: 埋点监控
@@ -373,6 +401,16 @@ export async function batchAutoGradeSubmissionsAction(
const failedCount = results.filter((r) => !r.success).length
const fullyGradedCount = results.filter((r) => r.success && r.isFullyAutoGraded).length
// 批改完成后自动采集错题 + 更新掌握度(仅对成功批改的提交)
await Promise.allSettled(
results
.filter(
(r): r is typeof r & { studentId: string } =>
r.success && typeof r.studentId === "string" && r.studentId.length > 0,
)
.map((r) => runPostGradingHooks(r.submissionId, r.studentId)),
)
revalidatePath("/teacher/homework/submissions")
revalidatePath("/teacher/homework/assignments")

View File

@@ -0,0 +1,136 @@
import "server-only"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignments,
homeworkSubmissions,
} from "@/shared/db/schema"
import { getExamSubjectIdMap } from "@/modules/exams/data-access"
/**
* 错题采集所需的答案数据(单题)
*/
export type AnswerForErrorCollection = {
questionId: string
answerContent: unknown
score: number | null
feedback: string | null
maxScore: number
}
/**
* 作业提交的错题采集数据
*/
export type HomeworkSubmissionDataForErrorCollection = {
assignmentId: string
subjectId: string | null
answers: AnswerForErrorCollection[]
}
/**
* 跨模块接口:获取作业提交的错题采集数据。
*
* 供 error-book 模块调用,避免 error-book 直接查询 homeworkSubmissions、
* homeworkAssignments、homeworkAnswers、homeworkAssignmentQuestions 等属于
* homework 模块的表,以及 exams 表(通过 exams 模块的 getExamSubjectIdMap 获取)。
*
* 返回该提交的所有答案(含得分、反馈、满分),由 error-book 模块
* 自行筛选错题score < maxScore并采集。
*
* @param submissionId 作业提交 ID
* @returns 提交数据;若提交不存在则返回 null
*/
export async function getHomeworkSubmissionDataForErrorCollection(
submissionId: string,
): Promise<HomeworkSubmissionDataForErrorCollection | null> {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
columns: { id: true, assignmentId: true },
})
if (!submission) return null
// 并行获取作业信息、提交答案、题目满分
const [assignment, answers, hwQuestionScores] = await Promise.all([
db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, submission.assignmentId),
columns: { id: true, sourceExamId: true },
}),
db
.select({
questionId: homeworkAnswers.questionId,
answerContent: homeworkAnswers.answerContent,
score: homeworkAnswers.score,
feedback: homeworkAnswers.feedback,
})
.from(homeworkAnswers)
.where(eq(homeworkAnswers.submissionId, submissionId)),
db
.select({
questionId: homeworkAssignmentQuestions.questionId,
maxScore: homeworkAssignmentQuestions.score,
})
.from(homeworkAssignmentQuestions)
.where(eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId)),
])
// 获取学科 ID若作业派生自试卷从源试卷获取 subjectId
let subjectId: string | null = null
if (assignment?.sourceExamId) {
const subjectIdMap = await getExamSubjectIdMap([assignment.sourceExamId])
subjectId = subjectIdMap.get(assignment.sourceExamId) ?? null
}
const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
const mappedAnswers: AnswerForErrorCollection[] = answers.map((a) => ({
questionId: a.questionId,
answerContent: a.answerContent,
score: a.score,
feedback: a.feedback,
maxScore: maxScoreMap.get(a.questionId) ?? 0,
}))
return {
assignmentId: submission.assignmentId,
subjectId,
answers: mappedAnswers,
}
}
/**
* 跨模块接口:获取作业提交的答案数据(供 diagnostic 模块更新掌握度使用)。
*
* 返回格式与 exams 模块的 `getExamSubmissionWithAnswers` 一致,
* 便于 diagnostic 模块统一处理考试提交和作业提交的掌握度更新。
*
* @param submissionId 作业提交 ID
* @returns `{ studentId, answers: Array<{ questionId, score }> }`;若提交不存在则返回 null
*/
export async function getHomeworkSubmissionWithAnswersForMastery(
submissionId: string,
): Promise<{ studentId: string; answers: Array<{ questionId: string; score: number | null }> } | null> {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
columns: { studentId: true },
})
if (!submission) return null
const answers = await db
.select({
questionId: homeworkAnswers.questionId,
score: homeworkAnswers.score,
})
.from(homeworkAnswers)
.where(eq(homeworkAnswers.submissionId, submissionId))
return {
studentId: submission.studentId,
answers,
}
}

View File

@@ -132,6 +132,7 @@ export const getHomeworkSubmissionForGrading = async (
assignmentId: string
creatorId: string
sourceExamId: string | null
studentId: string
} | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
@@ -143,6 +144,7 @@ export const getHomeworkSubmissionForGrading = async (
assignmentId: submission.assignmentId,
creatorId: submission.assignment.creatorId,
sourceExamId: submission.assignment.sourceExamId,
studentId: submission.studentId,
}
}
@@ -395,6 +397,7 @@ export const batchAutoGradeSubmissions = async (
success: boolean
isFullyAutoGraded: boolean
totalScore: number
studentId?: string
message?: string
}>> => {
const now = new Date()
@@ -403,6 +406,7 @@ export const batchAutoGradeSubmissions = async (
success: boolean
isFullyAutoGraded: boolean
totalScore: number
studentId?: string
message?: string
}> = []
@@ -410,7 +414,7 @@ export const batchAutoGradeSubmissions = async (
try {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
columns: { id: true, assignmentId: true },
columns: { id: true, assignmentId: true, studentId: true },
})
if (!submission) {
@@ -483,6 +487,7 @@ export const batchAutoGradeSubmissions = async (
success: true,
isFullyAutoGraded,
totalScore,
studentId: submission.studentId,
})
} catch (error) {
results.push({