feat(diagnostic): add export, stats service, and confidence utils
- Add export module for diagnostic report data export - Add stats-service for diagnostic analytics aggregation - Add confidence-utils for diagnostic confidence score calculations
This commit is contained in:
@@ -1,45 +1,35 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { desc, eq, inArray } from "drizzle-orm"
|
||||
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
|
||||
|
||||
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
|
||||
import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access"
|
||||
import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@/modules/exams/data-access"
|
||||
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
|
||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import {
|
||||
aggregateClassMastery,
|
||||
buildClassMasterySummary,
|
||||
buildStudentMasterySummary,
|
||||
computeKpStats,
|
||||
computeMasteryLevel,
|
||||
serializeMasteryWithKp,
|
||||
type RawClassMasteryRow,
|
||||
type RawMasteryWithKpRow,
|
||||
} from "./stats-service"
|
||||
import type {
|
||||
ClassMasterySummary,
|
||||
KnowledgePointMastery,
|
||||
KnowledgePointStat,
|
||||
MasteryWithKnowledgePoint,
|
||||
StudentMasterySummary,
|
||||
} from "./types"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100
|
||||
|
||||
const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({
|
||||
id: r.id,
|
||||
studentId: r.studentId,
|
||||
knowledgePointId: r.knowledgePointId,
|
||||
masteryLevel: toNumber(r.masteryLevel),
|
||||
totalQuestions: r.totalQuestions,
|
||||
correctQuestions: r.correctQuestions,
|
||||
lastAssessedAt: r.lastAssessedAt.toISOString(),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
/** 获取学生在所有知识点的掌握度(含知识点名称) */
|
||||
export const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
|
||||
const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
mastery: knowledgePointMastery,
|
||||
@@ -51,45 +41,29 @@ export const getStudentMastery = cache(async (studentId: string): Promise<Master
|
||||
.where(eq(knowledgePointMastery.studentId, studentId))
|
||||
.orderBy(desc(knowledgePointMastery.masteryLevel))
|
||||
|
||||
return rows.map((r) => ({
|
||||
...serializeMastery(r.mastery),
|
||||
knowledgePointName: r.kpName ?? "Unknown",
|
||||
knowledgePointDescription: r.kpDescription,
|
||||
}))
|
||||
return rows.map((r) =>
|
||||
serializeMasteryWithKp({
|
||||
mastery: r.mastery,
|
||||
kpName: r.kpName,
|
||||
kpDescription: r.kpDescription,
|
||||
} satisfies RawMasteryWithKpRow),
|
||||
)
|
||||
})
|
||||
|
||||
/** 获取学生掌握度摘要(含强项/弱项分析) */
|
||||
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
|
||||
const userMap = await getUserNamesByIds([studentId])
|
||||
// P3-18 修复:用户名查询与掌握度查询相互独立,并行执行
|
||||
const [userMap, allMastery] = await Promise.all([
|
||||
getUserNamesByIds([studentId]),
|
||||
getStudentMastery(studentId),
|
||||
])
|
||||
const student = userMap.get(studentId)
|
||||
if (!student) return null
|
||||
|
||||
const allMastery = await getStudentMastery(studentId)
|
||||
const averageMastery =
|
||||
allMastery.length > 0
|
||||
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
|
||||
: 0
|
||||
|
||||
// Single-pass classification: strengths (>=80) and weaknesses (<60)
|
||||
const strengths: MasteryWithKnowledgePoint[] = []
|
||||
const weaknesses: MasteryWithKnowledgePoint[] = []
|
||||
for (const m of allMastery) {
|
||||
if (m.masteryLevel >= 80) strengths.push(m)
|
||||
if (m.masteryLevel < 60) weaknesses.push(m)
|
||||
}
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
averageMastery,
|
||||
totalKnowledgePoints: allMastery.length,
|
||||
strengths,
|
||||
weaknesses,
|
||||
allMastery,
|
||||
}
|
||||
return buildStudentMasterySummary(studentId, student.name ?? "Unknown", allMastery)
|
||||
})
|
||||
|
||||
/** 从提交答案更新掌握度(正确率作为掌握度) */
|
||||
/** 从提交答案更新掌握度(累积模式:在历史基础上累加,正确率作为掌握度) */
|
||||
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
|
||||
const submission = await getExamSubmissionWithAnswers(submissionId)
|
||||
if (!submission) return
|
||||
@@ -115,31 +89,147 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
// 读取已有掌握度记录,累积计算(而非覆盖)
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(knowledgePointMastery)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgePointMastery.studentId, submission.studentId),
|
||||
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())),
|
||||
),
|
||||
)
|
||||
|
||||
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||||
for (const row of existingRows) {
|
||||
existingByKp.set(row.knowledgePointId, {
|
||||
total: row.totalQuestions,
|
||||
correct: row.correctQuestions,
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await Promise.all(
|
||||
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
||||
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
|
||||
await db
|
||||
.insert(knowledgePointMastery)
|
||||
.values({
|
||||
studentId: submission.studentId,
|
||||
knowledgePointId: kpId,
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions: stat.total,
|
||||
correctQuestions: stat.correct,
|
||||
lastAssessedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
// 使用事务保证多个 upsert 的原子性
|
||||
await db.transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
||||
const existing = existingByKp.get(kpId)
|
||||
const totalQuestions = (existing?.total ?? 0) + stat.total
|
||||
const correctQuestions = (existing?.correct ?? 0) + stat.correct
|
||||
const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||||
await tx
|
||||
.insert(knowledgePointMastery)
|
||||
.values({
|
||||
studentId: submission.studentId,
|
||||
knowledgePointId: kpId,
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions: stat.total,
|
||||
correctQuestions: stat.correct,
|
||||
totalQuestions,
|
||||
correctQuestions,
|
||||
lastAssessedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions,
|
||||
correctQuestions,
|
||||
lastAssessedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P1-5:从手动录入的成绩更新掌握度。
|
||||
*
|
||||
* 当教师手动录入关联了 examId 的成绩时,根据得分率(score/fullScore)
|
||||
* 更新该考试涉及的所有知识点的掌握度。采用累积模式,与 updateMasteryFromSubmission 一致。
|
||||
*
|
||||
* 注意:此函数假设学生在所有知识点上的掌握度等于整体得分率,
|
||||
* 这是一种近似(无法区分知识点级别的强弱),适用于无题目级别答案的场景。
|
||||
*/
|
||||
export async function updateMasteryFromExamScore(
|
||||
studentId: string,
|
||||
examId: string,
|
||||
score: number,
|
||||
fullScore: number,
|
||||
): Promise<void> {
|
||||
if (fullScore <= 0) return
|
||||
|
||||
// 获取考试的所有题目
|
||||
const examWithQuestions = await getExamWithQuestionsForHomework(examId)
|
||||
if (!examWithQuestions || examWithQuestions.questions.length === 0) return
|
||||
|
||||
// 获取题目关联的知识点
|
||||
const questionIds = examWithQuestions.questions.map((q) => q.questionId)
|
||||
const kpMap = await getKnowledgePointsForQuestions(questionIds)
|
||||
|
||||
// 收集所有涉及的知识点 ID
|
||||
const kpIds = new Set<string>()
|
||||
for (const links of kpMap.values()) {
|
||||
for (const link of links) {
|
||||
kpIds.add(link.knowledgePointId)
|
||||
}
|
||||
}
|
||||
|
||||
if (kpIds.size === 0) return
|
||||
|
||||
// 计算得分率作为掌握度
|
||||
const masteryLevel = computeMasteryLevel(score, fullScore)
|
||||
|
||||
// 读取已有掌握度记录,累积计算
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(knowledgePointMastery)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgePointMastery.studentId, studentId),
|
||||
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpIds)),
|
||||
),
|
||||
)
|
||||
|
||||
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||||
for (const row of existingRows) {
|
||||
existingByKp.set(row.knowledgePointId, {
|
||||
total: row.totalQuestions,
|
||||
correct: row.correctQuestions,
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// 使用事务保证多个 upsert 的原子性
|
||||
await db.transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
Array.from(kpIds).map(async (kpId) => {
|
||||
const existing = existingByKp.get(kpId)
|
||||
// 累积:将本次成绩视为 1 道题,得分率作为掌握度
|
||||
const totalQuestions = (existing?.total ?? 0) + 1
|
||||
const correctQuestions = (existing?.correct ?? 0) + Math.round(masteryLevel / 100)
|
||||
const newMasteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||||
await tx
|
||||
.insert(knowledgePointMastery)
|
||||
.values({
|
||||
studentId,
|
||||
knowledgePointId: kpId,
|
||||
masteryLevel: String(newMasteryLevel),
|
||||
totalQuestions,
|
||||
correctQuestions,
|
||||
lastAssessedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
masteryLevel: String(newMasteryLevel),
|
||||
totalQuestions,
|
||||
correctQuestions,
|
||||
lastAssessedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取班级掌握度摘要 */
|
||||
@@ -172,50 +262,16 @@ export const getClassMasterySummary = cache(async (classId: string): Promise<Cla
|
||||
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
|
||||
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
||||
|
||||
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
|
||||
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 })
|
||||
|
||||
for (const r of masteryRows) {
|
||||
const level = toNumber(r.mastery.masteryLevel)
|
||||
const kpId = r.mastery.knowledgePointId
|
||||
const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
|
||||
kpEntry.levels.push(level)
|
||||
if (level >= 80) kpEntry.mastered += 1
|
||||
if (level < 60) kpEntry.notMastered += 1
|
||||
byKp.set(kpId, kpEntry)
|
||||
|
||||
const stuEntry = byStudent.get(r.mastery.studentId)
|
||||
if (stuEntry) {
|
||||
stuEntry.levels.push(level)
|
||||
if (level < 60) stuEntry.weakCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({
|
||||
knowledgePointId: kpId,
|
||||
knowledgePointName: e.name,
|
||||
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
|
||||
masteredCount: e.mastered,
|
||||
notMasteredCount: e.notMastered,
|
||||
totalStudents: students.length,
|
||||
const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||||
mastery: {
|
||||
studentId: r.mastery.studentId,
|
||||
knowledgePointId: r.mastery.knowledgePointId,
|
||||
masteryLevel: r.mastery.masteryLevel,
|
||||
},
|
||||
kpName: r.kpName,
|
||||
}))
|
||||
|
||||
const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel))
|
||||
const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0
|
||||
|
||||
const studentsNeedingAttention = students
|
||||
.map((s) => {
|
||||
const e = byStudent.get(s.id)
|
||||
if (!e) return null
|
||||
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
|
||||
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
|
||||
})
|
||||
.filter((s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } => s !== null)
|
||||
.filter((s) => s.averageMastery < 60)
|
||||
.sort((a, b) => a.averageMastery - b.averageMastery)
|
||||
|
||||
return { classId, className, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
|
||||
return buildClassMasterySummary(classId, className, students, rawRows)
|
||||
})
|
||||
|
||||
/** 获取知识点统计(按班级或年级聚合) */
|
||||
@@ -235,23 +291,101 @@ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: s
|
||||
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||
.where(inArray(knowledgePointMastery.studentId, studentIds))
|
||||
|
||||
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||
for (const r of masteryRows) {
|
||||
const level = toNumber(r.mastery.masteryLevel)
|
||||
const kpId = r.mastery.knowledgePointId
|
||||
const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
|
||||
e.levels.push(level)
|
||||
if (level >= 80) e.mastered += 1
|
||||
if (level < 60) e.notMastered += 1
|
||||
byKp.set(kpId, e)
|
||||
}
|
||||
|
||||
return Array.from(byKp.entries()).map(([kpId, e]) => ({
|
||||
knowledgePointId: kpId,
|
||||
knowledgePointName: e.name,
|
||||
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
|
||||
masteredCount: e.mastered,
|
||||
notMasteredCount: e.notMastered,
|
||||
totalStudents: studentIds.length,
|
||||
const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||||
mastery: {
|
||||
studentId: r.mastery.studentId,
|
||||
knowledgePointId: r.mastery.knowledgePointId,
|
||||
masteryLevel: r.mastery.masteryLevel,
|
||||
},
|
||||
kpName: r.kpName,
|
||||
}))
|
||||
|
||||
const { byKp } = aggregateClassMastery(rawRows, studentIds)
|
||||
return computeKpStats(byKp)
|
||||
})
|
||||
|
||||
/**
|
||||
* v3-P2-5: 获取班级学生在指定知识点上的掌握度列表。
|
||||
*
|
||||
* 用于"按知识点筛选学生"功能:教师选择某个知识点后,列出班级所有学生
|
||||
* 在该知识点上的掌握度,便于针对性辅导。掌握度低于阈值(默认 60)的学生
|
||||
* 排在前面并标记为"需关注"。
|
||||
*/
|
||||
export const getClassStudentsByKnowledgePoint = cache(
|
||||
async (
|
||||
classId: string,
|
||||
knowledgePointId: string,
|
||||
options?: { threshold?: number }
|
||||
): Promise<
|
||||
Array<{
|
||||
studentId: string
|
||||
studentName: string
|
||||
masteryLevel: number
|
||||
totalQuestions: number
|
||||
correctQuestions: number
|
||||
lastAssessedAt: string | null
|
||||
needsAttention: boolean
|
||||
}>
|
||||
> => {
|
||||
const threshold = options?.threshold ?? 60
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const [userMap, masteryRows] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
db
|
||||
.select({
|
||||
mastery: knowledgePointMastery,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgePointMastery.knowledgePointId, knowledgePointId),
|
||||
inArray(knowledgePointMastery.studentId, studentIds),
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
const masteryByStudent = new Map<string, {
|
||||
masteryLevel: number
|
||||
totalQuestions: number
|
||||
correctQuestions: number
|
||||
lastAssessedAt: Date | null
|
||||
}>()
|
||||
for (const row of masteryRows) {
|
||||
masteryByStudent.set(row.mastery.studentId, {
|
||||
masteryLevel: Number(row.mastery.masteryLevel) || 0,
|
||||
totalQuestions: row.mastery.totalQuestions,
|
||||
correctQuestions: row.mastery.correctQuestions,
|
||||
lastAssessedAt: row.mastery.lastAssessedAt,
|
||||
})
|
||||
}
|
||||
|
||||
const result = studentIds.map((id) => {
|
||||
const info = userMap.get(id)
|
||||
const mastery = masteryByStudent.get(id)
|
||||
const masteryLevel = mastery?.masteryLevel ?? 0
|
||||
return {
|
||||
studentId: id,
|
||||
studentName: info?.name ?? "Unknown",
|
||||
masteryLevel,
|
||||
totalQuestions: mastery?.totalQuestions ?? 0,
|
||||
correctQuestions: mastery?.correctQuestions ?? 0,
|
||||
lastAssessedAt: mastery?.lastAssessedAt
|
||||
? mastery.lastAssessedAt.toISOString()
|
||||
: null,
|
||||
needsAttention: masteryLevel < threshold,
|
||||
}
|
||||
})
|
||||
|
||||
// 需关注的学生排前面,相同关注状态按掌握度升序
|
||||
result.sort((a, b) => {
|
||||
if (a.needsAttention !== b.needsAttention) {
|
||||
return a.needsAttention ? -1 : 1
|
||||
}
|
||||
return a.masteryLevel - b.masteryLevel
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user