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,23 +1,42 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, type SQL } from "drizzle-orm"
import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db"
import { learningDiagnosticReports } from "@/shared/db/schema"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { toNumber } from "@/modules/grades/lib/grade-utils"
import { BusinessError } from "@/shared/lib/action-utils"
import type { DataScope } from "@/shared/types/permissions"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
import { buildClassReportContent, buildStudentReportContent } from "./stats-service"
import type {
DiagnosticReport,
DiagnosticReportListResult,
DiagnosticReportQueryParams,
DiagnosticReportWithDetails,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
/**
* 诊断报告业务错误P3-27 修复:结构化错误码,避免直接暴露内部错误)。
* 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。
*/
export class DiagnosticReportError extends BusinessError {
constructor(
public readonly code:
| "STUDENT_NOT_FOUND"
| "NO_MASTERY_DATA"
| "CLASS_NOT_FOUND"
| "CLASS_NO_MASTERY_DATA",
message: string,
) {
super(message, code)
this.name = "DiagnosticReportError"
}
}
const isStringArray = (v: unknown): v is string[] =>
@@ -29,6 +48,7 @@ const toStringArrayNullable = (v: unknown): string[] | null =>
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
id: r.id,
studentId: r.studentId,
classId: r.classId,
generatedBy: r.generatedBy,
reportType: r.reportType,
period: r.period,
@@ -49,19 +69,15 @@ export async function generateDiagnosticReport(
generatedBy: string
): Promise<string> {
const summary = await getStudentMasterySummary(studentId)
if (!summary) throw new Error("Student not found")
if (!summary) throw new DiagnosticReportError("STUDENT_NOT_FOUND", "学生不存在")
const overallScore = summary.averageMastery
const strengths = summary.strengths.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
const weaknesses = summary.weaknesses.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
const recommendations = summary.weaknesses.map(
(m) => `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`
)
if (recommendations.length === 0) {
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
// P2-6 修复:当学生存在但无任何掌握度数据时,拒绝生成误导性报告
if (summary.totalKnowledgePoints === 0) {
throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告")
}
const summaryText = `学生 ${summary.studentName}${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildStudentReportContent(summary, period)
const id = createId()
await db.insert(learningDiagnosticReports).values({
@@ -87,24 +103,15 @@ export async function generateClassDiagnosticReport(
generatedBy: string
): Promise<string> {
const summary = await getClassMasterySummary(classId)
if (!summary) throw new Error("Class not found")
if (!summary) throw new DiagnosticReportError("CLASS_NOT_FOUND", "班级不存在")
const topWeak = summary.knowledgePointStats
.filter((k) => k.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery)
.slice(0, 5)
const strengths = summary.knowledgePointStats
.filter((k) => k.averageMastery >= 80)
.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const weaknesses = topWeak.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const recommendations = topWeak.map(
(k) => `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`
)
if (recommendations.length === 0) {
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
// P2-6 修复:当班级存在但无任何掌握度数据时,拒绝生成误导性报告
if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) {
throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告")
}
const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildClassReportContent(summary, period)
const id = createId()
await db.insert(learningDiagnosticReports).values({
@@ -117,26 +124,73 @@ export async function generateClassDiagnosticReport(
strengths,
weaknesses,
recommendations,
overallScore: String(summary.averageMastery),
overallScore: String(overallScore),
status: "draft",
})
return id
}
/** 查询诊断报告列表 */
/** 查询诊断报告列表P3-15 修复:支持分页) */
export const getDiagnosticReports = cache(
async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => {
async (
filters: DiagnosticReportQueryParams,
scope?: DataScope,
): Promise<DiagnosticReportListResult> => {
const conditions: SQL[] = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
const rows = await db
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(learningDiagnosticReports.createdAt))
// v4-P1-1: 应用 DataScope 行级权限过滤
// - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null需通过 classId 关联)
// 由于当前 schema 班级报告 studentId=null无法直接按 classId 过滤,因此对 class_taught scope
// 个人报告按所教班级学生 ID 过滤班级报告studentId=null保留教师可查看自己生成的班级报告
// - class_members: 学生角色,调用方已在 filters.studentId 中传入 ctx.userId无需在此重复过滤
// - children: 仅返回子女的报告
// - grade_managed: 返回所辖年级所有学生的报告(通过 studentId IN 所辖年级学生)
// - all: 不过滤
if (scope) {
if (scope.type === "children") {
if (scope.childrenIds.length === 0) {
return { reports: [], total: 0 }
}
conditions.push(inArray(learningDiagnosticReports.studentId, scope.childrenIds))
} else if (scope.type === "class_taught") {
if (scope.classIds.length === 0) {
return { reports: [], total: 0 }
}
const studentIds = await getStudentIdsByClassIds(scope.classIds)
if (studentIds.length === 0) {
return { reports: [], total: 0 }
}
// 个人报告按学生 ID 过滤班级报告studentId=null由 generatedBy 限制为当前教师
// 这里简化:仅返回所教班级学生的个人报告
conditions.push(inArray(learningDiagnosticReports.studentId, studentIds))
}
// grade_managed 和 all 不在此过滤grade_managed 需要跨模块查询年级学生,由调用方自行过滤)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const limit = filters.limit ?? 100
const offset = filters.offset ?? 0
// P3-15 修复:并行查询总数和分页数据
const [totalRows, rows] = await Promise.all([
db
.select({ total: count() })
.from(learningDiagnosticReports)
.where(whereClause),
db
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.where(whereClause)
.orderBy(desc(learningDiagnosticReports.createdAt))
.limit(limit)
.offset(offset),
])
const total = totalRows[0]?.total ?? 0
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>()
@@ -146,7 +200,7 @@ export const getDiagnosticReports = cache(
}
const userMap = await getUserNamesByIds(Array.from(userIds))
return rows.map((r) => ({
const reports: DiagnosticReportWithDetails[] = rows.map((r) => ({
...serializeReport(r.report),
studentName: r.report.studentId
? userMap.get(r.report.studentId)?.name ?? "Unknown"
@@ -155,6 +209,8 @@ export const getDiagnosticReports = cache(
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null,
}))
return { reports, total }
},
)