Files
OJ2/apps/api/src/services/learning-stats.ts
T
xuyueandClaude Opus 5.5 890e731a1c feat(智能分析): 后端改成按「每节课」给数据,AI 小结跟着改,不再把交了好几次的题夸成做得好
GET /ai/report 一次给整页:每天分课上的题和自己练的,和班上中间那位比,差一点的、错在哪、
知识点;时间段改成 这周 / 这个月 / 这学期 / 全部。删了 /ai/detail、/ai/solved、
/ai/duration、/ai/heatmap、/ai/pinned,换成 /ai/analysis/latest。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:20:34 +08:00

392 lines
14 KiB
TypeScript

/**
* 智能分析:页面(GET /ai/report)和喂给 AI 小结的学情数据都从这里算。
*
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts):
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
*
* 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
* (比赛是课堂测验,题目也不在题库里)。
*/
import {
FLOWCHART_PASS_GRADES,
STUDENT_ROLES,
type LearningDay,
type LearningItem,
type LearningPeriod,
type LearningReport,
} from "@oj2/contract"
import { and, asc, count, desc, eq, gte, inArray, isNull } from "drizzle-orm"
import { sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
import { calendarDay, monthStart, termStart, weekStart } from "../time"
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
const LESSON_MIN_USERS = 5
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
const NOT_STUDENT_ERRORS: number[] = [
JudgeStatus.SYSTEM_ERROR,
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]
export function periodStart(period: LearningPeriod, now = new Date()): string | null {
if (period === "week") return weekStart(now)
if (period === "month") return monthStart(now)
if (period === "term") return termStart(now)
return null
}
interface Row {
userId: number
problemId: number
time: number
solved: boolean
/** 代码提交的判题状态;流程图为 null */
result: number | null
}
/** 代码提交(不含比赛)和流程图提交并成一张,按时间先后 */
async function activity(userIds: number[], start: string | null): Promise<Row[]> {
const epoch = (column: typeof schema.submission.createTime) =>
sql<number>`extract(epoch from ${column}) * 1000`.mapWith(Number)
const [code, flow] = await Promise.all([
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
time: epoch(schema.submission.createTime),
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.userId, userIds),
isNull(schema.submission.contestId),
start ? gte(schema.submission.createTime, start) : undefined,
),
),
db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
time: sql<number>`extract(epoch from ${schema.flowchartSubmission.createTime}) * 1000`.mapWith(
Number,
),
status: schema.flowchartSubmission.status,
grade: schema.flowchartSubmission.aiGrade,
})
.from(schema.flowchartSubmission)
.where(
and(
inArray(schema.flowchartSubmission.userId, userIds),
start ? gte(schema.flowchartSubmission.createTime, start) : undefined,
),
),
])
const pass: readonly string[] = FLOWCHART_PASS_GRADES
return [
...code.map((row) => ({ ...row, solved: accepted.includes(row.result) })),
...flow.map((row) => ({
userId: row.userId,
problemId: row.problemId,
time: row.time,
solved: row.status === 2 && pass.includes(row.grade ?? ""),
result: null,
})),
].sort((a, b) => a.time - b.time)
}
/** 一个人在一道题上:第一次交的时刻、做对的时刻、到做对为止交了几次、没做对时最后一次的结果 */
interface Track {
first: number
solvedAt: number | null
attempts: number
last: number | null
}
function median(values: number[]) {
if (!values.length) return 0
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
}
export async function buildReport(
target: { id: number; username: string; className: string | null },
period: LearningPeriod,
options: { neighbors: boolean },
): Promise<LearningReport> {
const now = new Date()
const start = periodStart(period, now)
const members = target.className
? await db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.className, target.className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
.orderBy(asc(schema.user.username))
: []
const memberIds = new Set(members.map((item) => item.id))
const [rows, plans] = await Promise.all([
activity([...new Set([target.id, ...memberIds])], start),
target.className
? db
.select({ day: schema.classLesson.day, problemIds: schema.classLesson.problemIds })
.from(schema.classLesson)
.where(
and(
eq(schema.classLesson.className, target.className),
start ? gte(schema.classLesson.day, calendarDay(start)) : undefined,
),
)
: [],
])
// 每人每题的轨迹,和同班「某天某题」的人数、最早时刻(推断这节课、排题目顺序用)
const tracks = new Map<number, Map<number, Track>>()
const dayUsers = new Map<string, Set<number>>()
const dayFirst = new Map<string, number>()
const mine: Row[] = []
for (const row of rows) {
if (row.userId === target.id) mine.push(row)
let byProblem = tracks.get(row.userId)
if (!byProblem) tracks.set(row.userId, (byProblem = new Map()))
let track = byProblem.get(row.problemId)
if (!track)
byProblem.set(
row.problemId,
(track = { first: row.time, solvedAt: null, attempts: 0, last: null }),
)
if (track.solvedAt === null) {
track.attempts += 1
if (row.solved) track.solvedAt = row.time
else if (row.result !== null) track.last = row.result
}
if (!memberIds.has(row.userId)) continue
const key = `${calendarDay(row.time)}|${row.problemId}`
let users = dayUsers.get(key)
if (!users) dayUsers.set(key, (users = new Set()))
users.add(row.userId)
if (!dayFirst.has(key)) dayFirst.set(key, row.time)
}
const usersOn = (day: string, problemId: number) => dayUsers.get(`${day}|${problemId}`)?.size ?? 0
// 一道题只归到一天:老师布置过的归布置的那天(布置了好几天就挑人最多的),
// 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的
const lessonDay = new Map<number, string>()
const planOrder = new Map<string, number>()
for (const plan of plans) {
plan.problemIds.forEach((problemId, index) => {
planOrder.set(`${plan.day}|${problemId}`, index)
const current = lessonDay.get(problemId)
if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId))
lessonDay.set(problemId, plan.day)
})
}
const planned = new Set(lessonDay.keys())
for (const [key, users] of dayUsers) {
if (users.size < LESSON_MIN_USERS) continue
const [day, id] = key.split("|") as [string, string]
const problemId = Number(id)
if (planned.has(problemId)) continue
const current = lessonDay.get(problemId)
if (
!current ||
users.size > usersOn(current, problemId) ||
(users.size === usersOn(current, problemId) && day > current)
)
lessonDay.set(problemId, day)
}
const myTracks = tracks.get(target.id) ?? new Map<number, Track>()
const problemIds = [...new Set([...lessonDay.keys(), ...myTracks.keys()])]
const [problems, knowledgeTags, myTags] = await Promise.all([
problemIds.length
? db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
visible: schema.problem.visible,
contestId: schema.problem.contestId,
})
.from(schema.problem)
.where(inArray(schema.problem.id, problemIds))
: [],
db
.select({ name: schema.problemTag.name, total: count() })
.from(schema.problemTag)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.innerJoin(schema.problem, eq(schema.problem.id, schema.problemTags.problemId))
.where(
and(
eq(schema.problemTag.category, "knowledge"),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.groupBy(schema.problemTag.name)
.orderBy(desc(count()), asc(schema.problemTag.name)),
myTracks.size
? db
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(
and(
eq(schema.problemTag.category, "knowledge"),
inArray(schema.problemTags.problemId, [...myTracks.keys()]),
),
)
: [],
])
const problemById = new Map(problems.map((item) => [item.id, item]))
// 被藏起来的题、比赛题不当「这节课的题」—— 学生点进去是 404
for (const problemId of lessonDay.keys()) {
const problem = problemById.get(problemId)
if (!problem?.visible || problem.contestId !== null) lessonDay.delete(problemId)
}
function item(problemId: number): LearningItem {
const problem = problemById.get(problemId)
const track = myTracks.get(problemId)
let rank: number | null = null
let classSolved: number | null = null
if (memberIds.size) {
const solvedAt: [number, number][] = []
for (const id of memberIds) {
const at = tracks.get(id)?.get(problemId)?.solvedAt
if (at != null) solvedAt.push([at, id])
}
solvedAt.sort((a, b) => a[0] - b[0] || a[1] - b[1])
classSolved = solvedAt.length
const index = solvedAt.findIndex(([, id]) => id === target.id)
rank = index >= 0 ? index + 1 : null
}
const status = !track
? "none"
: track.solvedAt === null
? "fail"
: track.attempts === 1
? "first"
: "tries"
return {
displayId: problem?.displayId ?? "",
title: problem?.title ?? "",
status,
attempts: track?.attempts ?? 0,
rank,
classSolved,
lastResult: track && track.solvedAt === null ? track.last : null,
}
}
const byDay = new Map<string, { lesson: number[]; own: number[] }>()
const slot = (day: string) => {
let entry = byDay.get(day)
if (!entry) byDay.set(day, (entry = { lesson: [], own: [] }))
return entry
}
for (const [problemId, day] of lessonDay) slot(day).lesson.push(problemId)
for (const [problemId, track] of myTracks)
if (!lessonDay.has(problemId)) slot(calendarDay(track.first)).own.push(problemId)
const order = (day: string, problemId: number) =>
planOrder.get(`${day}|${problemId}`) ?? 1e6 + (dayFirst.get(`${day}|${problemId}`) ?? 0) / 1e3
const days: LearningDay[] = [...byDay]
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
.map(([day, entry]) => ({
day,
lesson: entry.lesson.sort((a, b) => order(day, a) - order(day, b)).map(item),
own: entry.own.sort((a, b) => myTracks.get(a)!.first - myTracks.get(b)!.first).map(item),
}))
const solvedIds = [...myTracks].filter(([, track]) => track.solvedAt !== null)
const solved = solvedIds.length
let classStats: LearningReport["classStats"] = null
if (memberIds.size) {
const counts: number[] = []
const firstPercents: number[] = []
for (const id of memberIds) {
const byProblem = tracks.get(id)
if (!byProblem) continue
let done = 0
let first = 0
for (const track of byProblem.values()) {
if (track.solvedAt === null) continue
done += 1
if (track.attempts === 1) first += 1
}
counts.push(done)
if (done) firstPercents.push((first / done) * 100)
}
if (counts.length)
classStats = {
size: counts.length,
medianSolved: median(counts),
medianFirstPercent: Math.round(median(firstPercents)),
rank: 1 + counts.filter((value) => value > solved).length,
}
}
const errorCounts = new Map<number, number>()
for (const row of mine) {
if (row.solved || row.result === null || NOT_STUDENT_ERRORS.includes(row.result)) continue
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
}
const tagCounts = new Map<string, { solved: number; tried: number }>()
for (const tag of myTags) {
const entry = tagCounts.get(tag.name) ?? { solved: 0, tried: 0 }
entry.tried += 1
if (myTracks.get(tag.problemId)?.solvedAt != null) entry.solved += 1
tagCounts.set(tag.name, entry)
}
let neighbors: LearningReport["neighbors"] = null
if (options.neighbors && members.length) {
const index = members.findIndex((item) => item.id === target.id)
if (index >= 0)
neighbors = {
prev: members[index - 1]?.username ?? null,
next: members[index + 1]?.username ?? null,
}
}
return {
username: target.username,
className: target.className,
period,
start,
end: now.toISOString(),
solved,
firstTry: solvedIds.filter(([, track]) => track.attempts === 1).length,
lessonCount: days.filter((day) => day.lesson.length).length,
submissionCount: mine.length,
unsolved: [...myTracks]
.filter(([, track]) => track.solvedAt === null)
.sort((a, b) => b[1].attempts - a[1].attempts)
.map(([problemId]) => item(problemId)),
classStats,
days,
errors: [...errorCounts]
.map(([result, value]) => ({ result, count: value }))
.sort((a, b) => b.count - a.count || a.result - b.result),
knowledge: knowledgeTags.map((tag) => ({
name: tag.name,
...(tagCounts.get(tag.name) ?? { solved: 0, tried: 0 }),
})),
neighbors,
} satisfies LearningReport
}