feat(班级PK): 班级 PK 只比两个班都布置过的同一批题,按做对的人占几成分输赢;登录了就能看,AI 分析由服务端现算
- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比; 显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算) - 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班, 一样多挑班级对抗排得更前的 - 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算) - /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c6e4a1e7c
commit
d83804b814
7 files changed
+390
-240
No files matched your search
@@ -23,7 +23,7 @@ import { generateFilteredHint } from "../services/hint-filter"
|
||||
import { buildDetail, buildDuration, listSolved } from "../services/learning-stats"
|
||||
import { decideHintLevel } from "../services/hint-level"
|
||||
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis"
|
||||
import { classDetail } from "../services/ranking"
|
||||
import { classDetail, classPk } from "../services/ranking"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time"
|
||||
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers"
|
||||
@@ -385,8 +385,46 @@ aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const limited = await throttleAi(c)
|
||||
if (limited) return limited
|
||||
// 数字和页面上的是同一份(services/ranking.ts 的 classPk),不再由前端整包传上来
|
||||
const pk = await classPk([...new Set(parsed.data.classNames)], parsed.data.period, null)
|
||||
const label = (name: string) => `${name.slice(0, 2)}计算机${name.slice(2)}班`
|
||||
const facts = {
|
||||
时间段: parsed.data.period === "week" ? "这周" : "这学期",
|
||||
各班: pk.classes.map((item) => ({
|
||||
班级: label(item.className),
|
||||
人数: item.members,
|
||||
人均做对: item.perCapita,
|
||||
中间那位做对: item.median,
|
||||
做对过题的人: item.solvedMembers,
|
||||
一次就对百分比: item.firstPercent,
|
||||
同一批题里领先几道: item.lead,
|
||||
这学期每周人均新做对: item.weekly,
|
||||
})),
|
||||
同一批题: pk.problems.map((problem) => ({
|
||||
题目: problem.title,
|
||||
各班: problem.cells.map((cell, index) =>
|
||||
cell
|
||||
? {
|
||||
班级: label(pk.classes[index]!.className),
|
||||
做对的人占全班百分比: cell.percent,
|
||||
一次就对百分比: cell.firstPercent,
|
||||
交过的人: cell.tried,
|
||||
最好: cell.best,
|
||||
}
|
||||
: { 班级: label(pk.classes[index]!.className), 没布置: true },
|
||||
),
|
||||
})),
|
||||
只有一个班布置过的题数: pk.solo.length,
|
||||
}
|
||||
return streamChat(
|
||||
"你是编程教育数据分析专家。根据多个班级 OJ 对比数据,从排名、参与度、典型学生水平、均衡性、梯队、提交质量和教学建议七方面输出中文 Markdown 报告。",
|
||||
`${parsed.data.timeRangeLabel}\n${JSON.stringify(parsed.data.comparisons)}`,
|
||||
[
|
||||
"你是编程课老师的助教。下面是几个班在 OJ 上的对比数据(JSON)。各班进度不同、布置的题不一样,",
|
||||
"所以比较以「同一批题」(至少两个班都布置过的题)为主,人均做对只作参考。",
|
||||
"用中文 Markdown 写一份给老师看的简短分析,分三到四段,每段一两句,段首加粗:",
|
||||
"**整体**(人均和同一批题里各班领先几道,人均差距有多少是进度造成的)、",
|
||||
"**差得最多的题**(点题目名,哪个班明显低)、**一次就对**(哪个班更稳、哪个班靠多交几次)、",
|
||||
"**建议**(下节课能直接做的事)。用大白话,不要出现四分位数、标准差这类统计词,不要编数据里没有的数字。",
|
||||
].join(""),
|
||||
JSON.stringify(facts),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
isSqlProblem,
|
||||
classComparisonRequestSchema,
|
||||
STUDENT_ROLES,
|
||||
classLessonRequestSchema,
|
||||
type ClassActivity,
|
||||
@@ -8,26 +7,11 @@ import {
|
||||
type ClassBoardStudent,
|
||||
type ClassLesson,
|
||||
type ClassActivityProblem,
|
||||
type ClassComparison,
|
||||
type ClassComparisonResponse,
|
||||
type LastVisit,
|
||||
type LessonLanguage,
|
||||
FLOWCHART_PASS_GRADES,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
lt,
|
||||
lte,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
@@ -37,75 +21,9 @@ import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { accepted } from "../services/learning-stats"
|
||||
import { calendarDay, dayStart, localTime } from "../time"
|
||||
import { rounded } from "./helpers"
|
||||
|
||||
export const classroomRoutes = new Hono<AppEnv>()
|
||||
|
||||
interface ClassUser {
|
||||
userId: number
|
||||
username: string
|
||||
className: string
|
||||
acceptedNumber: number
|
||||
submissionNumber: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班),
|
||||
* 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍,
|
||||
* 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。
|
||||
*/
|
||||
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
|
||||
const filters = [
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
sql`${schema.user.className} is not null`,
|
||||
]
|
||||
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
||||
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
})
|
||||
.from(schema.user)
|
||||
.innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(...filters))
|
||||
return rows.filter((row): row is ClassUser => row.className !== null)
|
||||
}
|
||||
|
||||
function mean(values: number[]) {
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (!values.length) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||
}
|
||||
|
||||
function quantile(values: number[], p: number) {
|
||||
if (values.length <= 1) return values[0] ?? 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const position = (sorted.length + 1) * p - 1
|
||||
if (position <= 0) return sorted[0]!
|
||||
if (position >= sorted.length - 1) return sorted.at(-1)!
|
||||
const lower = Math.floor(position)
|
||||
const fraction = position - lower
|
||||
return sorted[lower]! + (sorted[lower + 1]! - sorted[lower]!) * fraction
|
||||
}
|
||||
|
||||
function sampleStdDev(values: number[]) {
|
||||
if (values.length <= 1) return 0
|
||||
const average = mean(values)
|
||||
return Math.sqrt(
|
||||
values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
|
||||
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
|
||||
@@ -892,111 +810,3 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
const users = await loadClassUsers(parsed.data.classNames)
|
||||
const allAc = users.map((user) => user.acceptedNumber)
|
||||
const globalQ1 = quantile(allAc, 0.25)
|
||||
const globalQ3 = quantile(allAc, 0.75)
|
||||
const byClass = new Map<string, ClassUser[]>()
|
||||
for (const user of users)
|
||||
byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
|
||||
|
||||
let recentByUser = new Map<number, Set<number>>()
|
||||
let recentSubmissionCount = new Map<string, number>()
|
||||
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
|
||||
if (hasTimeRange) {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
schema.submission.userId,
|
||||
users.map((user) => user.userId),
|
||||
),
|
||||
gte(schema.submission.createTime, parsed.data.startTime!),
|
||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
||||
),
|
||||
)
|
||||
const userClass = new Map(users.map((user) => [user.userId, user.className]))
|
||||
for (const row of rows) {
|
||||
const className = userClass.get(row.userId)
|
||||
if (!className) continue
|
||||
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
|
||||
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
|
||||
const set = recentByUser.get(row.userId) ?? new Set<number>()
|
||||
set.add(row.problemId)
|
||||
recentByUser.set(row.userId, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comparisons = [...byClass].map(([className, members]) => {
|
||||
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
|
||||
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
|
||||
const userCount = members.length
|
||||
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
|
||||
const bottomCount = topCount
|
||||
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
|
||||
const totalAc = ac.reduce((sum, value) => sum + value, 0)
|
||||
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
|
||||
const base: ClassComparison = {
|
||||
className,
|
||||
userCount,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(mean(ac)),
|
||||
medianAc: rounded(median(ac)),
|
||||
q1Ac: rounded(quantile(ac, 0.25)),
|
||||
q3Ac: rounded(quantile(ac, 0.75)),
|
||||
iqr: rounded(quantile(ac, 0.75) - quantile(ac, 0.25)),
|
||||
stdDev: rounded(sampleStdDev(ac)),
|
||||
top10Avg: rounded(mean(ac.slice(0, topCount))),
|
||||
middle80Avg: rounded(mean(middle)),
|
||||
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
|
||||
excellentRate: rounded((ac.filter((value) => value >= globalQ3).length / userCount) * 100),
|
||||
passRate: rounded((ac.filter((value) => value >= globalQ1).length / userCount) * 100),
|
||||
activeRate: rounded((submissions.filter((value) => value > 0).length / userCount) * 100),
|
||||
acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
|
||||
compositeScore: 0,
|
||||
}
|
||||
if (hasTimeRange) {
|
||||
const recent = members
|
||||
.map((member) => recentByUser.get(member.userId)?.size ?? 0)
|
||||
.sort((a, b) => b - a)
|
||||
base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
|
||||
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
|
||||
base.recentAvgAc = rounded(mean(recent))
|
||||
base.recentMedianAc = rounded(median(recent))
|
||||
base.recentTop10Avg = rounded(
|
||||
mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))),
|
||||
)
|
||||
base.recentActiveCount = recent.filter((value) => value > 0).length
|
||||
}
|
||||
return base
|
||||
})
|
||||
const maxMedian = Math.max(1, ...comparisons.map((item) => item.medianAc))
|
||||
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
|
||||
for (const item of comparisons) {
|
||||
item.compositeScore = rounded(
|
||||
0.4 * ((item.medianAc / maxMedian) * 100) +
|
||||
0.15 * ((item.middle80Avg / maxMiddle) * 100) +
|
||||
0.2 * item.activeRate +
|
||||
0.15 * item.passRate +
|
||||
0.1 * item.excellentRate,
|
||||
1,
|
||||
)
|
||||
}
|
||||
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
|
||||
return success(c, {
|
||||
comparisons,
|
||||
hasTimeRange,
|
||||
} satisfies ClassComparisonResponse)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
classPkPeriodSchema,
|
||||
rankHiddenRequestSchema,
|
||||
rankPeriodSchema,
|
||||
rankScopeSchema,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { dayStart, termStart, weekStart } from "../time"
|
||||
@@ -21,7 +22,10 @@ import {
|
||||
audienceWhere,
|
||||
classBattle,
|
||||
classDetail,
|
||||
classPk,
|
||||
loadEntrants,
|
||||
PK_MAX_CLASSES,
|
||||
pkPartner,
|
||||
rankedStudents,
|
||||
standings,
|
||||
WEEK_MS,
|
||||
@@ -252,6 +256,38 @@ rankingRoutes.get("/rankings/class-detail", optionalAuth, async (c) => {
|
||||
return success(c, await classDetail(className, isTeacherOrAbove(c.get("user"))))
|
||||
})
|
||||
|
||||
/**
|
||||
* 班级 PK(设计稿「班级 PK 重设计」定稿):`classes` 逗号分隔。谁登录了都能看 —— 只有班级
|
||||
* 层面的数,没有点名到人。只给一个班(或者不给,用自己的班 / 老师默认的班)就配一个对手,
|
||||
* 见 `pkPartner`。
|
||||
*/
|
||||
rankingRoutes.get("/rankings/pk", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const period = classPkPeriodSchema.safeParse(c.req.query("period") ?? "term")
|
||||
if (!period.success) return failure(c, 400, "invalid-query", "时间段不对")
|
||||
const requested = [
|
||||
...new Set(
|
||||
(c.req.query("classes") ?? "")
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name && name.length <= 32),
|
||||
),
|
||||
]
|
||||
if (requested.length > PK_MAX_CLASSES)
|
||||
return failure(c, 400, "too-many-classes", `一次最多比 ${PK_MAX_CLASSES} 个班`)
|
||||
|
||||
const isTeacher = isTeacherOrAbove(user)
|
||||
const mine = isTeacher ? null : user.className || null
|
||||
const classNames = requested.length
|
||||
? requested
|
||||
: [isTeacher ? await defaultClassFor(user.id) : mine].filter((name): name is string => !!name)
|
||||
if (classNames.length === 1) {
|
||||
const partner = await pkPartner(classNames[0]!)
|
||||
if (partner) classNames.push(partner)
|
||||
}
|
||||
return success(c, await classPk(classNames, period.data, mine))
|
||||
})
|
||||
|
||||
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
|
||||
rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => {
|
||||
const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim())
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
STUDENT_ROLES,
|
||||
type ClassBattleItem,
|
||||
type ClassDetail,
|
||||
type ClassPk,
|
||||
type ClassPkCell,
|
||||
type ClassPkClass,
|
||||
type ClassPkPeriod,
|
||||
type RankScope,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
like,
|
||||
min,
|
||||
ne,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm"
|
||||
|
||||
@@ -308,3 +313,221 @@ export async function classDetail(className: string, withCare: boolean): Promise
|
||||
care,
|
||||
} satisfies ClassDetail
|
||||
}
|
||||
|
||||
/** 一次最多比几个班(对照格再宽就放不下了) */
|
||||
export const PK_MAX_CLASSES = 8
|
||||
|
||||
interface PkPair {
|
||||
className: string
|
||||
problemId: number
|
||||
solved: boolean
|
||||
firstSolved: boolean
|
||||
submissions: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 这几个班从 `since` 起每个人每道题:交了几次、做对没有、第一次交就对没有。比赛里的提交不算,
|
||||
* 入榜人群和排名页一样(不计入排名的人不算)。「第一次」是这段时间里的第一次。
|
||||
*/
|
||||
async function pkPairs(classNames: string[], since: string): Promise<PkPair[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
className: schema.user.className,
|
||||
problemId: schema.submission.problemId,
|
||||
submissions: count(),
|
||||
solved: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
||||
first: sql<number>`(array_agg(${schema.submission.result} order by ${schema.submission.createTime}, ${schema.submission.id}))[1]`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(
|
||||
rankedStudents,
|
||||
inArray(schema.user.className, classNames),
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, since),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className, schema.submission.userId, schema.submission.problemId)
|
||||
return rows.map((row) => ({
|
||||
className: row.className!,
|
||||
problemId: row.problemId,
|
||||
solved: row.solved,
|
||||
firstSolved: (SOLVED_RESULTS as number[]).includes(row.first),
|
||||
submissions: row.submissions,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 每个班布置过哪些题:过半的人交过就算 */
|
||||
function assignedSets(pairs: PkPair[], members: Map<string, number>) {
|
||||
const tried = new Map<string, Map<number, number>>()
|
||||
for (const pair of pairs) {
|
||||
const byProblem = tried.get(pair.className) ?? new Map<number, number>()
|
||||
byProblem.set(pair.problemId, (byProblem.get(pair.problemId) ?? 0) + 1)
|
||||
tried.set(pair.className, byProblem)
|
||||
}
|
||||
const sets = new Map<string, Set<number>>()
|
||||
for (const [className, byProblem] of tried) {
|
||||
const n = members.get(className) ?? 0
|
||||
sets.set(
|
||||
className,
|
||||
new Set([...byProblem].filter(([, people]) => n > 0 && people * 2 >= n).map(([id]) => id)),
|
||||
)
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
function memberCounts(entrants: Entrant[]) {
|
||||
const members = new Map<string, number>()
|
||||
for (const entrant of entrants)
|
||||
members.set(entrant.className!, (members.get(entrant.className!) ?? 0) + 1)
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* 只给了一个班(或者没给、用看的人自己的班)时,配一个对手:同年级在用的班里,和它一起
|
||||
* 布置过的题最多的那个;一样多挑班级对抗里排得更前的(用户 2026-10-10 定的)。
|
||||
* 同年级没有别的在用的班就不配。
|
||||
*/
|
||||
export async function pkPartner(className: string) {
|
||||
const { term, entrants } = await classedEntrants()
|
||||
const battle = battleFrom(entrants)
|
||||
const grade = className.slice(0, 2)
|
||||
const candidates = battle.filter(
|
||||
(item) => item.className !== className && item.className.slice(0, 2) === grade,
|
||||
)
|
||||
if (!candidates.length) return null
|
||||
const names = [className, ...candidates.map((item) => item.className)]
|
||||
const sets = assignedSets(
|
||||
await pkPairs(names, term),
|
||||
memberCounts(entrants.filter((entrant) => names.includes(entrant.className!))),
|
||||
)
|
||||
const mine = sets.get(className) ?? new Set<number>()
|
||||
const common = (name: string) => [...(sets.get(name) ?? [])].filter((id) => mine.has(id)).length
|
||||
return candidates.sort((a, b) => common(b.className) - common(a.className) || a.rank - b.rank)[0]!
|
||||
.className
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级 PK 的全部数字(页面和 AI 分析共用)。人均、分布按 `period` 算;每周人均(赛跑)
|
||||
* 一直按这学期,横轴才有东西。
|
||||
*/
|
||||
export async function classPk(
|
||||
classNames: string[],
|
||||
period: ClassPkPeriod,
|
||||
mine: string | null,
|
||||
): Promise<ClassPk> {
|
||||
const { term, entrants: all } = await classedEntrants()
|
||||
const since = period === "week" ? weekStart() : term
|
||||
const sinceMs = Date.parse(since)
|
||||
const battle = battleFrom(all)
|
||||
const entrants = all.filter((entrant) => classNames.includes(entrant.className!))
|
||||
const members = memberCounts(entrants)
|
||||
const pairs = await pkPairs(classNames, since)
|
||||
const assigned = assignedSets(pairs, members)
|
||||
|
||||
const firstWeek = Date.parse(weekStart(term))
|
||||
const thisWeek = Date.parse(weekStart())
|
||||
const weeks: number[] = []
|
||||
for (let from = firstWeek; from <= thisWeek; from += WEEK_MS) weeks.push(from)
|
||||
|
||||
// 每道题、每个班:交过 / 做对 / 一次就对 / 提交次数
|
||||
const cells = new Map<
|
||||
number,
|
||||
Map<string, Omit<ClassPkCell, "percent" | "firstPercent" | "best">>
|
||||
>()
|
||||
for (const pair of pairs) {
|
||||
if (!assigned.get(pair.className)?.has(pair.problemId)) continue
|
||||
const byClass = cells.get(pair.problemId) ?? new Map()
|
||||
const cell = byClass.get(pair.className) ?? { tried: 0, solved: 0, firstTry: 0, submissions: 0 }
|
||||
cell.tried++
|
||||
if (pair.solved) cell.solved++
|
||||
if (pair.firstSolved) cell.firstTry++
|
||||
cell.submissions += pair.submissions
|
||||
byClass.set(pair.className, cell)
|
||||
cells.set(pair.problemId, byClass)
|
||||
}
|
||||
const titles = cells.size
|
||||
? await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(inArray(schema.problem.id, [...cells.keys()]))
|
||||
: []
|
||||
const titleOf = new Map(titles.map((row) => [row.id, row]))
|
||||
|
||||
const lead = new Map<string, number>()
|
||||
const problems: ClassPk["problems"] = []
|
||||
const solo: ClassPk["solo"] = []
|
||||
for (const [problemId, byClass] of [...cells].sort((a, b) => a[0] - b[0])) {
|
||||
const meta = titleOf.get(problemId)
|
||||
if (!meta) continue
|
||||
if (byClass.size === 1) {
|
||||
const [className] = byClass.keys()
|
||||
solo.push({ problemId, displayId: meta.displayId, title: meta.title, className: className! })
|
||||
continue
|
||||
}
|
||||
const row = classNames.map((className) => {
|
||||
const cell = byClass.get(className)
|
||||
if (!cell) return null
|
||||
return {
|
||||
...cell,
|
||||
percent: Math.round((cell.solved / members.get(className)!) * 100),
|
||||
firstPercent: Math.round((cell.firstTry / cell.tried) * 100),
|
||||
best: false,
|
||||
}
|
||||
})
|
||||
const top = Math.max(...row.map((cell) => cell?.percent ?? -1))
|
||||
row.forEach((cell, index) => {
|
||||
if (!cell || cell.percent !== top) return
|
||||
cell.best = true
|
||||
lead.set(classNames[index]!, (lead.get(classNames[index]!) ?? 0) + 1)
|
||||
})
|
||||
problems.push({ problemId, displayId: meta.displayId, title: meta.title, cells: row })
|
||||
}
|
||||
|
||||
const classes = classNames.map((className) => {
|
||||
const people = entrants.filter((entrant) => entrant.className === className)
|
||||
const solved = people.map((entrant) => entrant.times.filter((time) => time >= sinceMs).length)
|
||||
const own = pairs.filter((pair) => pair.className === className)
|
||||
return {
|
||||
className,
|
||||
members: people.length,
|
||||
perCapita: people.length ? rounded(average(solved), 1) : 0,
|
||||
median: median(solved),
|
||||
solvedMembers: solved.filter((value) => value > 0).length,
|
||||
firstPercent: own.length
|
||||
? Math.round((own.filter((pair) => pair.firstSolved).length / own.length) * 100)
|
||||
: null,
|
||||
lead: lead.get(className) ?? 0,
|
||||
battleRank: battle.find((item) => item.className === className)?.rank ?? null,
|
||||
distribution: solved,
|
||||
weekly: weeks.map((from) =>
|
||||
people.length
|
||||
? rounded(
|
||||
average(
|
||||
people.map(
|
||||
(entrant) =>
|
||||
entrant.times.filter((time) => time >= from && time < from + WEEK_MS).length,
|
||||
),
|
||||
),
|
||||
1,
|
||||
)
|
||||
: 0,
|
||||
),
|
||||
} satisfies ClassPkClass
|
||||
})
|
||||
|
||||
return {
|
||||
period,
|
||||
start: since,
|
||||
weeks: weeks.map((from) => new Date(from).toISOString()),
|
||||
classes,
|
||||
mine,
|
||||
problems,
|
||||
solo,
|
||||
} satisfies ClassPk
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { classPkPeriodSchema } from "./ranking"
|
||||
|
||||
/**
|
||||
* 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 ——
|
||||
* 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。
|
||||
@@ -240,9 +242,10 @@ export const classAnalysisRequestSchema = z.object({
|
||||
className: z.string().trim().min(1).max(32),
|
||||
})
|
||||
|
||||
/** 班级 PK 的 AI 分析同样只传班号和时间段,数字和页面上的是同一份(services/ranking.ts 的 classPk) */
|
||||
export const classPkAnalysisRequestSchema = z.object({
|
||||
comparisons: z.array(z.record(z.string(), z.unknown())).min(2),
|
||||
timeRangeLabel: z.string().default("全部时间"),
|
||||
classNames: z.array(z.string().trim().min(1).max(32)).min(2).max(8),
|
||||
period: classPkPeriodSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,44 +3,6 @@ import { z } from "zod"
|
||||
import { judgeStatusSchema } from "./judge-status"
|
||||
import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language"
|
||||
|
||||
export const classComparisonSchema = z.object({
|
||||
className: z.string(),
|
||||
userCount: z.number().int(),
|
||||
totalAc: z.number().int(),
|
||||
totalSubmission: z.number().int(),
|
||||
avgAc: z.number(),
|
||||
medianAc: z.number(),
|
||||
q1Ac: z.number(),
|
||||
q3Ac: z.number(),
|
||||
iqr: z.number(),
|
||||
stdDev: z.number(),
|
||||
top10Avg: z.number(),
|
||||
middle80Avg: z.number(),
|
||||
bottom10Avg: z.number(),
|
||||
excellentRate: z.number(),
|
||||
passRate: z.number(),
|
||||
activeRate: z.number(),
|
||||
acRate: z.number(),
|
||||
compositeScore: z.number(),
|
||||
recentTotalAc: z.number().int().optional(),
|
||||
recentTotalSubmission: z.number().int().optional(),
|
||||
recentAvgAc: z.number().optional(),
|
||||
recentMedianAc: z.number().optional(),
|
||||
recentTop10Avg: z.number().optional(),
|
||||
recentActiveCount: z.number().int().optional(),
|
||||
})
|
||||
|
||||
export const classComparisonRequestSchema = z.object({
|
||||
classNames: z.array(z.string()).min(1),
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
})
|
||||
|
||||
export const classComparisonResponseSchema = z.object({
|
||||
comparisons: z.array(classComparisonSchema),
|
||||
hasTimeRange: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
|
||||
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
|
||||
@@ -200,9 +162,6 @@ export const classLessonSchema = z.object({
|
||||
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
|
||||
})
|
||||
|
||||
export type ClassComparison = z.infer<typeof classComparisonSchema>
|
||||
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema>
|
||||
|
||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||
export type LastVisit = z.infer<typeof lastVisitSchema>
|
||||
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
|
||||
@@ -212,5 +171,3 @@ export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
|
||||
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
|
||||
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
|
||||
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
|
||||
|
||||
export type ClassComparisonRequest = z.infer<typeof classComparisonRequestSchema>
|
||||
@@ -146,3 +146,86 @@ export const classDetailSchema = z.object({
|
||||
})
|
||||
|
||||
export type ClassDetail = z.infer<typeof classDetailSchema>
|
||||
|
||||
/**
|
||||
* 班级 PK(设计稿「班级 PK 重设计」定稿)。用户定的:**两个班按「同一批题」对决,三个班以上
|
||||
* 按题对照,学生也能看**。
|
||||
*
|
||||
* 各班进度不同、布置的题不一样,比人均等于比谁开课早,所以主角是「同一批题」:
|
||||
* 一个班过半的人交过这道题就算这个班**布置过**;至少两个班布置过的题才拿来比,
|
||||
* 比的是**做对的人占全班几成**(整数百分比),高的那个班赢这道,显示一样就算平 ——
|
||||
* 不然 37/39 和 38/40 都显示 95%,皇冠却只给一边。口径和排名页一样:比赛里的提交不算,
|
||||
* 不计入排名的人分子分母都不算。
|
||||
*/
|
||||
export const classPkPeriodSchema = z.enum(["week", "term"])
|
||||
|
||||
export const classPkCellSchema = z.object({
|
||||
/** 这段时间交过这道题的人 */
|
||||
tried: z.number().int().nonnegative(),
|
||||
/** 这段时间做对了的人 */
|
||||
solved: z.number().int().nonnegative(),
|
||||
/** 第一次交就对的人 */
|
||||
firstTry: z.number().int().nonnegative(),
|
||||
submissions: z.number().int().nonnegative(),
|
||||
/** 做对的人占全班几成(整数百分比),比输赢就比它 */
|
||||
percent: z.number().int().nonnegative(),
|
||||
/** 一次就对:交过的人里第一次交就对的占几成 */
|
||||
firstPercent: z.number().int().nonnegative(),
|
||||
/** 这道题做得最好的班(一样多就都算) */
|
||||
best: z.boolean(),
|
||||
})
|
||||
|
||||
export const classPkProblemSchema = z.object({
|
||||
problemId: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
/** 和 `classes` 一一对应;null = 这个班没布置 */
|
||||
cells: z.array(classPkCellSchema.nullable()),
|
||||
})
|
||||
|
||||
export const classPkClassSchema = z.object({
|
||||
className: z.string(),
|
||||
members: z.number().int().nonnegative(),
|
||||
/** 这段时间人均做对(这道题第一次做对落在这段时间里) */
|
||||
perCapita: z.number(),
|
||||
/** 中间那位同学做对几道 */
|
||||
median: z.number(),
|
||||
solvedMembers: z.number().int().nonnegative(),
|
||||
/** 这段时间交过的题里第一次交就对的比例(整数百分比);一道没交过为 null */
|
||||
firstPercent: z.number().int().nonnegative().nullable(),
|
||||
/** 同一批题里做得最好(含并列)的有几道 */
|
||||
lead: z.number().int().nonnegative(),
|
||||
/** 班级对抗里第几;这学期还没做对过题的班没有名次 */
|
||||
battleRank: z.number().int().positive().nullable(),
|
||||
/** 每个同学这段时间做对几道 */
|
||||
distribution: z.array(z.number().int().nonnegative()),
|
||||
/** 这学期每周人均新做对,和 `weeks` 一一对应(不随「这周 / 这学期」变) */
|
||||
weekly: z.array(z.number()),
|
||||
})
|
||||
|
||||
export const classPkSchema = z.object({
|
||||
period: classPkPeriodSchema,
|
||||
start: z.string(),
|
||||
/** 这学期每周的周一零点(东八区),赛跑和每周人均的横轴 */
|
||||
weeks: z.array(z.string()),
|
||||
classes: z.array(classPkClassSchema),
|
||||
/** 看的人自己的班(学生);老师和没班级的为 null */
|
||||
mine: z.string().nullable(),
|
||||
/** 至少两个班布置过的题,按题号 */
|
||||
problems: z.array(classPkProblemSchema),
|
||||
/** 只有一个班布置过的题:不比,只列出来 */
|
||||
solo: z.array(
|
||||
z.object({
|
||||
problemId: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
className: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type ClassPkPeriod = z.infer<typeof classPkPeriodSchema>
|
||||
export type ClassPkCell = z.infer<typeof classPkCellSchema>
|
||||
export type ClassPkProblem = z.infer<typeof classPkProblemSchema>
|
||||
export type ClassPkClass = z.infer<typeof classPkClassSchema>
|
||||
export type ClassPk = z.infer<typeof classPkSchema>
|
||||
Reference in new issue
Block a user