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,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 }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user