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:
SpecialX
2026-06-23 17:37:58 +08:00
parent 1abf58c0b6
commit 9ceb2b7b67
12 changed files with 1717 additions and 436 deletions

View File

@@ -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
}
)