以前往届那一侧把所有记录都算进去,25 级比 24 级显示 73% / 82%, 其实 24 级在这些题上近一半是学了半年以上才做的;同期比是 73% / 75%。 现在每个人以自己第一次交题为起点,两边都只算离起点不超过 N 天的人题对, N = 两届里学得短的那届一半人开始做题到现在的天数。按人不按日历,因为各届 开始做题的时间不一样(25 级第二年才开始)。比哪届改成后端算,接口带 grade / compare。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3c0507d41
commit
f7c4af0c01
5 files changed
+176
-98
No files matched your search
@@ -20,7 +20,7 @@ import {
|
||||
STUDENT_ROLES,
|
||||
type StuckProblem,
|
||||
type StuckProblemDetail,
|
||||
type GradeTrendProblem,
|
||||
type GradeTrend,
|
||||
} from "@oj2/contract"
|
||||
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -243,45 +243,125 @@ adminAnalyticsRoutes.get("/problem-analytics/stuck/:id", requireTeacher, async (
|
||||
|
||||
// ---------------------------------------------------------------- 年度趋势:这届和往届比
|
||||
|
||||
/** 一个格子(题 × 届)至少这么多人做过才给,再少的前端也不会拿来比 */
|
||||
const GRADE_CELL_MIN_USERS = 5
|
||||
/** 每边至少这么多人做过,这道题才拿来比 */
|
||||
const GRADE_MIN_USERS = 20
|
||||
/** 没指定届时,挑最新一届里能和往届比上这么多道题的 */
|
||||
const GRADE_DEFAULT_MIN_PROBLEMS = 5
|
||||
const PAST = "past"
|
||||
|
||||
type GradeSide = GradeTrend["problems"][number]["mine"]
|
||||
|
||||
/**
|
||||
* 每道题每一届的「一次就对」。届 = 班号前两位(253 → 25 级),没有班级或班号不是数字的不算。
|
||||
* 「第一次交」按这个人在这道题上的全部历史算,和卡点同一个口径。比哪届、门槛多少都在前端,
|
||||
* 换选项不用再取一遍。
|
||||
* 选一届,和往届(更早几届合在一起,人多的届分量大)或另一届,逐题比「一次就对」。
|
||||
* 届 = 班号前两位(253 → 25 级),没有班级或班号不是数字的不算。
|
||||
*
|
||||
* **按同期比**:每个人以自己第一次交题为起点,两边都只算第一次交这道题时离起点不超过
|
||||
* N 天的人题对,N = 两边里学得最短的那届已经学了多久(那届一半人开始做题到现在的天数)。
|
||||
* 不这么卡的话,刚学一个月的 25 级会和学了一年多回头做同一道题的 24 级比 —— 本地数据里
|
||||
* 同期两边 73% / 75%,全部记录一比成了 73% / 82%。起点按人不按日历:各届开始做题的时间
|
||||
* 不一样(23 级入学就做、25 级第二年才做),对「去年同期」是对不上的。
|
||||
*/
|
||||
adminAnalyticsRoutes.get("/problem-analytics/grades", requireTeacher, async (c) => {
|
||||
const rows = await db.execute<{
|
||||
id: number
|
||||
display_id: string
|
||||
title: string
|
||||
grade: string
|
||||
users: number
|
||||
first_try: number
|
||||
}>(sql`
|
||||
with s as (${studentSubmissions([sql`u.class_name ~ '^[0-9]{3,}$'`])}),
|
||||
pairs as (
|
||||
select problem_id, left(min(class_name), 2) as grade,
|
||||
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try
|
||||
from s group by user_id, problem_id
|
||||
)
|
||||
select p.id, p._id as display_id, p.title, pairs.grade,
|
||||
count(*)::int as users, count(*) filter (where first_try)::int as first_try
|
||||
from pairs join ${schema.problem} p on p.id = pairs.problem_id
|
||||
group by p.id, pairs.grade
|
||||
having count(*) >= ${GRADE_CELL_MIN_USERS}
|
||||
`)
|
||||
const problems = new Map<number, GradeTrendProblem>()
|
||||
for (const row of rows) {
|
||||
const problem = problems.get(row.id) ?? {
|
||||
id: row.id,
|
||||
displayId: row.display_id,
|
||||
title: row.title,
|
||||
grades: [],
|
||||
const [cells, starts] = await Promise.all([
|
||||
db.execute<{
|
||||
problem_id: number
|
||||
grade: string
|
||||
days: number
|
||||
users: number
|
||||
first_try: number
|
||||
}>(sql`
|
||||
with s as (${studentSubmissions([sql`u.class_name ~ '^[0-9]{3,}$'`])}),
|
||||
starts as (select user_id, min(create_time) as started_at from s group by user_id),
|
||||
pairs as (
|
||||
select problem_id, left(min(class_name), 2) as grade,
|
||||
floor(extract(epoch from min(create_time) - min(started_at)) / 86400)::int as days,
|
||||
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try
|
||||
from s join starts using (user_id)
|
||||
group by user_id, problem_id
|
||||
)
|
||||
select problem_id, grade, days, count(*)::int as users,
|
||||
count(*) filter (where first_try)::int as first_try
|
||||
from pairs group by 1, 2, 3
|
||||
`),
|
||||
// 每届学了多久:一半人开始做题到现在的天数(几个提前注册的人拉不动它)
|
||||
db.execute<{ grade: string; days: number }>(sql`
|
||||
with starts as (
|
||||
select left(min(u.class_name), 2) as grade, min(s.create_time) as started_at
|
||||
from ${schema.submission} s join ${schema.user} u on u.id = s.user_id
|
||||
where s.contest_id is null and s.result not in (${NOT_COUNTED})
|
||||
and u.admin_type in (${STUDENTS}) and u.class_name ~ '^[0-9]{3,}$'
|
||||
group by s.user_id
|
||||
)
|
||||
select grade, floor(extract(epoch from now()
|
||||
- percentile_disc(0.5) within group (order by started_at)) / 86400)::int as days
|
||||
from starts group by grade
|
||||
`),
|
||||
])
|
||||
const studied = new Map(starts.map((row) => [row.grade, row.days]))
|
||||
const grades = [...new Set(cells.map((cell) => cell.grade))].sort((a, b) => b.localeCompare(a))
|
||||
|
||||
function compare(grade: string, other: string) {
|
||||
const isOther = other === PAST ? (g: string) => g < grade : (g: string) => g === other
|
||||
const involved = grades.filter((g) => g === grade || isOther(g))
|
||||
const limit = Math.min(...involved.map((g) => studied.get(g) ?? Infinity))
|
||||
const sides = new Map<number, { mine: GradeSide; theirs: GradeSide }>()
|
||||
let trimmed = false
|
||||
for (const cell of cells) {
|
||||
const mine = cell.grade === grade
|
||||
if (!mine && !isOther(cell.grade)) continue
|
||||
if (cell.days > limit) {
|
||||
trimmed = true
|
||||
continue
|
||||
}
|
||||
const entry = sides.get(cell.problem_id) ?? {
|
||||
mine: { users: 0, firstTry: 0 },
|
||||
theirs: { users: 0, firstTry: 0 },
|
||||
}
|
||||
const side = mine ? entry.mine : entry.theirs
|
||||
side.users += cell.users
|
||||
side.firstTry += cell.first_try
|
||||
sides.set(cell.problem_id, entry)
|
||||
}
|
||||
problem.grades.push({ grade: row.grade, users: row.users, firstTry: row.first_try })
|
||||
problems.set(row.id, problem)
|
||||
const problems = [...sides]
|
||||
.filter(([, e]) => e.mine.users >= GRADE_MIN_USERS && e.theirs.users >= GRADE_MIN_USERS)
|
||||
.map(([id, e]) => ({ id, ...e }))
|
||||
return { days: trimmed ? limit : null, problems }
|
||||
}
|
||||
return success(c, [...problems.values()] satisfies GradeTrendProblem[])
|
||||
|
||||
const asked = c.req.query("grade") ?? ""
|
||||
const grade = grades.includes(asked)
|
||||
? asked
|
||||
: (grades.find((g) => compare(g, PAST).problems.length >= GRADE_DEFAULT_MIN_PROBLEMS) ??
|
||||
grades[0] ??
|
||||
"")
|
||||
const askedCompare = c.req.query("compare") ?? PAST
|
||||
const other = askedCompare !== grade && grades.includes(askedCompare) ? askedCompare : PAST
|
||||
const result = grade ? compare(grade, other) : { days: null, problems: [] }
|
||||
|
||||
const titles = result.problems.length
|
||||
? await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
inArray(
|
||||
schema.problem.id,
|
||||
result.problems.map((row) => row.id),
|
||||
),
|
||||
)
|
||||
: []
|
||||
const byId = new Map(titles.map((row) => [row.id, row]))
|
||||
return success(c, {
|
||||
grades,
|
||||
grade,
|
||||
compare: other,
|
||||
days: result.days,
|
||||
problems: result.problems.flatMap((row) => {
|
||||
const problem = byId.get(row.id)
|
||||
return problem ? [{ ...problem, mine: row.mine, theirs: row.theirs }] : []
|
||||
}),
|
||||
} satisfies GradeTrend)
|
||||
})
|
||||
@@ -50,7 +50,7 @@ import type {
|
||||
AdminAiReportList,
|
||||
StuckProblem,
|
||||
StuckProblemDetail,
|
||||
GradeTrendProblem,
|
||||
GradeTrend,
|
||||
LearnBoard,
|
||||
LearnExerciseProgressList,
|
||||
LearnExerciseAttemptList,
|
||||
@@ -768,8 +768,8 @@ export function getLearnStudent(userId: number, params: { type: "python" | "c" }
|
||||
return api.get<LearnStudentDetail>(`admin/learn-analytics/students/${userId}`, { params })
|
||||
}
|
||||
|
||||
export function getGradeTrend() {
|
||||
return api.get<GradeTrendProblem[]>("admin/problem-analytics/grades")
|
||||
export function getGradeTrend(params: { grade?: string; compare?: string }) {
|
||||
return api.get<GradeTrend>("admin/problem-analytics/grades", { params })
|
||||
}
|
||||
|
||||
// AI 学习分析报告
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import type { GradeTrendProblem } from "utils/types"
|
||||
import type { GradeTrend } from "utils/types"
|
||||
import { getGradeTrend } from "admin/api"
|
||||
import "oj/learn/learn.css"
|
||||
|
||||
@@ -9,73 +9,39 @@ import "oj/learn/learn.css"
|
||||
* 逐题比「一次就对」,比往届差的排前面。
|
||||
*
|
||||
* 按届(班号前两位)比而不是按自然年:一个学年里同时有两三届在做题,按年切会把它们搅在一起。
|
||||
* 往届是把更早几届的人合在一起算(人多的届分量大),不是几个百分比再平均。
|
||||
* 两边按同期比、每边至少几个人、默认挑哪届,都在后端(见 /problem-analytics/grades)。
|
||||
* 比输赢按显示出来的整数百分比,差 2 个点以内算差不多。
|
||||
*/
|
||||
const router = useRouter()
|
||||
|
||||
/** 每边至少这么多人做过,这道题才拿来比 */
|
||||
/** 和后端的 GRADE_MIN_USERS 一致,只用在空状态的文案里 */
|
||||
const MIN_USERS = 20
|
||||
const SAME = 2
|
||||
const PAST = "past"
|
||||
|
||||
const problems = ref<GradeTrendProblem[]>([])
|
||||
const data = ref<GradeTrend | null>(null)
|
||||
const loading = ref(false)
|
||||
const grade = useRouteQuery<string>("grade", "", { mode: "replace" })
|
||||
const compare = useRouteQuery<string>("compare", PAST, { mode: "replace" })
|
||||
|
||||
/** 有数据的届,新的在前 */
|
||||
const grades = computed(() =>
|
||||
[...new Set(problems.value.flatMap((row) => row.grades.map((cell) => cell.grade)))].sort((a, b) =>
|
||||
b.localeCompare(a),
|
||||
),
|
||||
)
|
||||
|
||||
function side(row: GradeTrendProblem, pick: (g: string) => boolean) {
|
||||
return row.grades
|
||||
.filter((cell) => pick(cell.grade))
|
||||
.reduce(
|
||||
(sum, cell) => ({ users: sum.users + cell.users, firstTry: sum.firstTry + cell.firstTry }),
|
||||
{
|
||||
users: 0,
|
||||
firstTry: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
const grades = computed(() => data.value?.grades ?? [])
|
||||
const selected = computed(() => data.value?.grade ?? "")
|
||||
|
||||
function rate(part: number, whole: number) {
|
||||
return whole ? Math.round((part / whole) * 100) : 0
|
||||
}
|
||||
|
||||
function compareRows(current: string, other: string) {
|
||||
const isOther = other === PAST ? (g: string) => g < current : (g: string) => g === other
|
||||
return problems.value
|
||||
.map((row) => {
|
||||
const mine = side(row, (g) => g === current)
|
||||
const theirs = side(row, isOther)
|
||||
const now = rate(mine.firstTry, mine.users)
|
||||
const before = rate(theirs.firstTry, theirs.users)
|
||||
return { row, mine, theirs, now, before, delta: now - before }
|
||||
})
|
||||
.filter((item) => item.mine.users >= MIN_USERS && item.theirs.users >= MIN_USERS)
|
||||
}
|
||||
|
||||
/** 没选就挑最新的一届里能和往届比上至少 5 道题的 */
|
||||
const selected = computed(() => {
|
||||
if (grades.value.includes(grade.value)) return grade.value
|
||||
return grades.value.find((g) => compareRows(g, PAST).length >= 5) ?? grades.value[0] ?? ""
|
||||
})
|
||||
|
||||
const pastGrades = computed(() => grades.value.filter((g) => g < selected.value))
|
||||
const pastLabel = computed(() => {
|
||||
const list = pastGrades.value
|
||||
if (!list.length) return "往届"
|
||||
return list.length === 1 ? `往届(${list[0]} 级)` : `往届(${list.at(-1)}–${list[0]} 级)`
|
||||
})
|
||||
const otherValue = computed(() => data.value?.compare ?? PAST)
|
||||
const otherLabel = computed(() =>
|
||||
compare.value === PAST ? pastLabel.value : `${compare.value} 级`,
|
||||
otherValue.value === PAST ? pastLabel.value : `${otherValue.value} 级`,
|
||||
)
|
||||
const shortOther = computed(() => (compare.value === PAST ? "往届" : `${compare.value} 级`))
|
||||
const shortOther = computed(() => (otherValue.value === PAST ? "往届" : `${otherValue.value} 级`))
|
||||
|
||||
const gradeOptions = computed(() => grades.value.map((g) => ({ label: `${g} 级`, value: g })))
|
||||
const compareOptions = computed(() => [
|
||||
@@ -83,7 +49,13 @@ const compareOptions = computed(() => [
|
||||
...grades.value.filter((g) => g !== selected.value).map((g) => ({ label: `${g} 级`, value: g })),
|
||||
])
|
||||
|
||||
const rows = computed(() => (selected.value ? compareRows(selected.value, compare.value) : []))
|
||||
const rows = computed(() =>
|
||||
(data.value?.problems ?? []).map((row) => {
|
||||
const now = rate(row.mine.firstTry, row.mine.users)
|
||||
const before = rate(row.theirs.firstTry, row.theirs.users)
|
||||
return { row, mine: row.mine, theirs: row.theirs, now, before, delta: now - before }
|
||||
}),
|
||||
)
|
||||
const worse = computed(() =>
|
||||
rows.value.filter((item) => item.delta < -SAME).sort((a, b) => a.delta - b.delta),
|
||||
)
|
||||
@@ -131,14 +103,22 @@ function pickGrade(value: string) {
|
||||
if (compare.value === value) compare.value = PAST
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
let token = 0
|
||||
async function load() {
|
||||
const current = ++token
|
||||
loading.value = true
|
||||
try {
|
||||
problems.value = await getGradeTrend()
|
||||
const result = await getGradeTrend({
|
||||
grade: grade.value || undefined,
|
||||
compare: compare.value === PAST ? undefined : compare.value,
|
||||
})
|
||||
if (current === token) data.value = result
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (current === token) loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch([grade, compare], load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,7 +135,7 @@ onMounted(async () => {
|
||||
/>
|
||||
<span class="muted">和</span>
|
||||
<n-select
|
||||
:value="compare"
|
||||
:value="otherValue"
|
||||
:options="compareOptions"
|
||||
class="compare-select"
|
||||
@update:value="(value: string) => (compare = value)"
|
||||
@@ -170,7 +150,11 @@ onMounted(async () => {
|
||||
<div class="value num">{{ rows.length }}<span class="unit"> 道</span></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">一次就对</div>
|
||||
<div class="label">
|
||||
一次就对<template v-if="data?.days !== null">
|
||||
· 各自开始做题的头 {{ data?.days }} 天</template
|
||||
>
|
||||
</div>
|
||||
<div class="value num">
|
||||
{{ summary.now }}%<span class="unit"> / {{ shortOther }} {{ summary.before }}%</span>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +330,7 @@ export type {
|
||||
StuckProblem,
|
||||
StuckProblemDetail,
|
||||
StuckStudent,
|
||||
GradeTrendProblem,
|
||||
GradeTrend,
|
||||
LearnBoard,
|
||||
LearnBoardStudent,
|
||||
LearnExerciseProgress,
|
||||
|
||||
@@ -647,13 +647,27 @@ export const stuckProblemDetailSchema = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/** 年度趋势:一道题在各届(班号前两位)的「一次就对」,只给至少 5 人做过的格子 */
|
||||
export const gradeTrendProblemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
grades: z.array(
|
||||
z.object({ grade: z.string(), users: z.number().int(), firstTry: z.number().int() }),
|
||||
const gradeTrendSideSchema = z.object({ users: z.number().int(), firstTry: z.number().int() })
|
||||
|
||||
/**
|
||||
* 年度趋势:选中的一届(grade)和往届或另一届(compare)在两边都做过的题上逐题比「一次就对」。
|
||||
* `days` 是同期窗口:两边都只算「第一次交这道题时,离他自己第一次交题不超过这么多天」的人题对;
|
||||
* 两边的数据都没超出窗口时为 null(= 全部)。
|
||||
*/
|
||||
export const gradeTrendSchema = z.object({
|
||||
/** 有数据的届,新的在前 */
|
||||
grades: z.array(z.string()),
|
||||
grade: z.string(),
|
||||
compare: z.string(),
|
||||
days: z.number().int().nullable(),
|
||||
problems: z.array(
|
||||
z.object({
|
||||
id: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
mine: gradeTrendSideSchema,
|
||||
theirs: gradeTrendSideSchema,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1149,7 +1163,7 @@ export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
|
||||
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
||||
export type StuckStudent = z.infer<typeof stuckStudentSchema>
|
||||
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
|
||||
export type GradeTrendProblem = z.infer<typeof gradeTrendProblemSchema>
|
||||
export type GradeTrend = z.infer<typeof gradeTrendSchema>
|
||||
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
||||
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
||||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||||
|
||||
Reference in new issue
Block a user