From 890e731a1c63f3e27d216ece899e09ea7a05d6b0 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 12 Oct 2026 23:20:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=99=BA=E8=83=BD=E5=88=86=E6=9E=90):=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=94=B9=E6=88=90=E6=8C=89=E3=80=8C=E6=AF=8F?= =?UTF-8?q?=E8=8A=82=E8=AF=BE=E3=80=8D=E7=BB=99=E6=95=B0=E6=8D=AE=EF=BC=8C?= =?UTF-8?q?AI=20=E5=B0=8F=E7=BB=93=E8=B7=9F=E7=9D=80=E6=94=B9=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=E4=BA=A4=E4=BA=86=E5=A5=BD=E5=87=A0?= =?UTF-8?q?=E6=AC=A1=E7=9A=84=E9=A2=98=E5=A4=B8=E6=88=90=E5=81=9A=E5=BE=97?= =?UTF-8?q?=E5=A5=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ai/report 一次给整页:每天分课上的题和自己练的,和班上中间那位比,差一点的、错在哪、 知识点;时间段改成 这周 / 这个月 / 这学期 / 全部。删了 /ai/detail、/ai/solved、 /ai/duration、/ai/heatmap、/ai/pinned,换成 /ai/analysis/latest。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/ai.ts | 240 +++---- apps/api/src/services/learning-stats.ts | 804 ++++++++++-------------- apps/api/src/time.ts | 8 + packages/contract/src/ai.ts | 176 +++--- 4 files changed, 507 insertions(+), 721 deletions(-) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index c9504e5..6995e55 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -5,15 +5,15 @@ import { classAnalysisRequestSchema, classPkAnalysisRequestSchema, HINT_MIN_FAILURES, + LEARNING_PERIOD_LABELS, + learningPeriodSchema, type AiAnalysisRecord, - type AiDetail, type AiHintDone, - type DurationData, type HintDiagnosis, - type HeatmapItem, - type SolvedProblem, + type LearningItem, + type LearningReport, } from "@oj2/contract" -import { and, count, eq, gte, inArray, lte, sql } from "drizzle-orm" +import { and, desc, eq, inArray, or, sql } from "drizzle-orm" import { Hono, type Context } from "hono" import { requireAuth, type AppEnv } from "../auth/middleware" @@ -23,12 +23,12 @@ import { JudgeStatus, judgeStatusName } from "../judge/status" import { failure, parseBody, readJson, success } from "../http" import { streamChat, streamWhole } from "../services/ai" import { generateFilteredHint } from "../services/hint-filter" -import { buildDetail, buildDuration, listSolved } from "../services/learning-stats" +import { buildReport } from "../services/learning-stats" import { decideHintLevel } from "../services/hint-level" import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis" import { classDetail, classPk } from "../services/ranking" import { consumeToken } from "../services/throttling" -import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time" +import { dayNumber, localWeekday } from "../time" import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers" export const aiRoutes = new Hono() @@ -68,83 +68,41 @@ async function targetUser(c: Context, override?: string) { return target ?? null } -aiRoutes.get("/ai/detail", requireAuth, async (c) => { - const start = c.req.query("start") - const end = c.req.query("end") - if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) { - return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps") - } +aiRoutes.get("/ai/report", requireAuth, async (c) => { + const period = learningPeriodSchema.safeParse(c.req.query("period") ?? "term") + if (!period.success) + return failure(c, 400, "invalid-period", "period must be week, month, term or all") const user = await targetUser(c) if (!user) return failure(c, 404, "user-not-found", "User not found") - return success(c, await buildDetail(user, start, end)) + // 老师看别人时给出同班前后一个人,好在页面上 ‹ › 翻 + const viewingOther = user.id !== c.get("user")!.id + return success(c, await buildReport(user, period.data, { neighbors: viewingOther })) }) -aiRoutes.get("/ai/solved", requireAuth, async (c) => { - const start = c.req.query("start") - const end = c.req.query("end") - if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) { - return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps") - } +/** + * 页面上「AI 帮你分析」那张卡要显示的小结:老师在后台钉住的那份优先(不分时间段), + * 否则是这个时间段最近生成的一份。都没有就是 null,前端出「AI 帮我分析」按钮。 + */ +aiRoutes.get("/ai/analysis/latest", requireAuth, async (c) => { + const period = learningPeriodSchema.safeParse(c.req.query("period") ?? "term") + if (!period.success) + return failure(c, 400, "invalid-period", "period must be week, month, term or all") const user = await targetUser(c) if (!user) return failure(c, 404, "user-not-found", "User not found") - const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 100 }) - const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) - return success(c, await listSolved(user, start, end, limit, offset)) -}) - -aiRoutes.get("/ai/duration", requireAuth, async (c) => { - const endText = c.req.query("end") - if (!endText || Number.isNaN(Date.parse(endText))) - return failure(c, 400, "invalid-end", "end must be an ISO timestamp") - const user = await targetUser(c) - if (!user) return failure(c, 404, "user-not-found", "User not found") - return success(c, await buildDuration(user, endText, c.req.query("duration") ?? "months:1")) -}) - -aiRoutes.get("/ai/heatmap", requireAuth, async (c) => { - const user = await targetUser(c) - if (!user) return failure(c, 404, "user-not-found", "User not found") - const end = new Date() - // 一格一周,共 53 格,最后一格是「本周」。周一算一周的开头(不用 GitHub 的周日)。 - // 整段以东八区的**日历日序号**为单位算(`dayNumber` / `dayText`),不构造本地 Date。 - const today = dayNumber(calendarDay(end)) - const mondayOffset = (localWeekday(today) + 6) % 7 - const firstMonday = today - mondayOffset - 52 * 7 - // SQL 两端各放宽一天:范围只用来少拉行,精确匹配靠下面按日历日 key 查表 - const date = sql`date(${localTime(schema.submission.createTime)})::text` - const rows = await db - .select({ date, value: count() }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - gte(schema.submission.createTime, new Date((firstMonday - 1) * 864e5).toISOString()), - lte(schema.submission.createTime, new Date(end.getTime() + 864e5).toISOString()), - ), - ) - .groupBy(date) - .orderBy(date) - const counts = new Map(rows.map((row) => [row.date, row.value])) - return success( - c, - Array.from({ length: 53 }, (_, week) => { - const monday = firstMonday + week * 7 - let value = 0 - for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0 - // timestamp 是该周周一的 UTC 零点,前端按东八区只取年月日部件 - return { timestamp: monday * 864e5, value } satisfies HeatmapItem - }), - ) -}) - -aiRoutes.get("/ai/pinned", requireAuth, async (c) => { const [row] = await db .select({ analysis: schema.aiAnalysis, username: schema.user.username }) .from(schema.aiAnalysis) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .where( - and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true)), + and( + eq(schema.aiAnalysis.userId, user.id), + or( + eq(schema.aiAnalysis.isPinned, true), + sql`${schema.aiAnalysis.data}->>'period' = ${period.data}`, + ), + ), ) + .orderBy(desc(schema.aiAnalysis.isPinned), desc(schema.aiAnalysis.createTime)) .limit(1) if (!row) return success(c, null) return success(c, { @@ -159,130 +117,98 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => { } satisfies AiAnalysisRecord) }) -const ANALYSIS_RANGE: Record = { - "hours:1": "这一节课", - "hours:2": "这两节课", - "days:1": "最近一天", - "weeks:1": "最近一周", - "months:1": "最近一个月", - "months:2": "最近两个月", - "months:6": "最近半年", - "years:1": "最近一年", -} +const WEEKDAY = "日一二三四五六" /** - * 喂给模型的学情数据,全部翻成中文、带单位。原来是 detail / duration 原样 JSON 进 - * prompt,模型读不懂裸编码:「错误类型 -1」(答案错误)被说成编译错误, - * `period 1` 原样出现在报告里,用户名 xuyue 被猜成了真名。 + * 喂给模型的学情数据,全部翻成中文、带单位,和页面同一份(buildReport)。原来是 + * detail / duration 原样 JSON 进 prompt,模型读不懂裸编码:「错误类型 -1」(答案错误) + * 被说成编译错误,`period 1` 原样出现在报告里,用户名 xuyue 被猜成了真名。 * - * 时间活跃度不给:八成提交在课上,几点做题是课表定的,模型只会据此编出 - * 「你是早起鸟」「改到工作日分散练」这类建议。名字和班级也不给,报告里一律称「你」。 + * 节奏按「每节课」给,不再按从现在往回推的一周一周切 —— 那样切出来的桶边界在半夜, + * 标签写「9月23日起」其实是 24 日起,模型据此说「23 日那周一道没做」,而那天做对了两道。 + * 名字和班级不给,报告里一律称「你」。 */ -function analysisFacts( - range: string, - details: AiDetail, - periods: DurationData[], - solved: SolvedProblem[], -) { - const scope = details.rankScope === "class" ? "班上" : "全站" - // 两个超时码合成一种说法;判题机自己崩的不算学生的错 +function analysisFacts(report: LearningReport) { + const result = (item: LearningItem) => + item.status === "first" + ? "一次就对" + : item.status === "tries" + ? `交了 ${item.attempts} 次才对` + : item.status === "fail" + ? `交了 ${item.attempts} 次还没做对` + : "没交" + const problem = (item: LearningItem) => ({ 题目: item.title, 结果: result(item) }) + const date = (day: string) => { + const [, month, date] = day.split("-").map(Number) + return `${month}月${date}日 周${WEEKDAY[localWeekday(dayNumber(day))]}` + } + // 两个超时码合成一种说法 const errors = new Map() - for (const item of details.errors) { - if (item.result === JudgeStatus.SYSTEM_ERROR) continue + for (const item of report.errors) { const name = judgeStatusName(item.result) errors.set(name, (errors.get(name) ?? 0) + item.count) } - const monthDay = (iso: string) => { - const [, month, day] = calendarDay(iso).split("-").map(Number) - return `${month}月${day}日` - } - // 一周以内的范围,buildDuration 给的是前四周,和「这段时间」对不上,不给 - const showTrend = ["months:1", "months:2", "months:6", "years:1"].includes(range) return { - 时间段: ANALYSIS_RANGE[range] ?? "这段时间", - 做对的题数: details.solvedCount, - 一次就对的题数: details.attempts.filter((value) => value === 1).length, - 难度: details.difficulty, - 做得最多的知识点: details.tags, - 总等级: details.grade || "无", + 时间段: LEARNING_PERIOD_LABELS[report.period], + 做对的题数: report.solved, + 一次就对的题数: report.firstTry, + 提交次数: report.submissionCount, + ...(report.classStats && { + 班上中间那位做对的题数: report.classStats.medianSolved, + 班上中间那位一次就对的百分比: report.classStats.medianFirstPercent, + }), 没通过的提交: [...errors].map(([结果, 次数]) => ({ 结果, 次数 })), - ...(showTrend && { - 分段: periods.map((item) => ({ - 时间: `${monthDay(item.start)}起的一${item.unit === "weeks" ? "周" : "个月"}`, - 做对几道题: item.problemCount, - 提交次数: item.submissionCount, - 等级: item.grade || "无", - })), - }), - ...(details.flowcharts.length && { - 流程图: details.flowcharts.map((item) => ({ - 题目: item.problemTitle, - 画了几次: item.submissionCount, - 最好等级: item.bestGrade || "无", - })), - }), - 做对的题: solved.map((item) => ({ - 题目: item.problem.title, - 难度: item.difficulty, - 交了几次才对: item.attempts, - ...(item.periodRank && { - 第几个做对: `${scope}第 ${item.periodRank} 个(共 ${item.periodAcCount} 人做对)`, - }), - 等级: item.grade || "无", - ...(item.problem.contestTitle && { 课堂测验: item.problem.contestTitle }), + 做过的知识点: report.knowledge + .filter((item) => item.tried) + .map((item) => ({ 知识点: item.name, 做对几道: item.solved })), + // 新的在前;「全部」可能有上百天,只给最近 30 天,够模型说节奏 + 每天: report.days.slice(0, 30).map((day) => ({ + 日期: date(day.day), + ...(day.lesson.length && { 课上的题: day.lesson.map(problem) }), + ...(day.own.length && { 自己练的题: day.own.map(problem) }), })), } } aiRoutes.post("/ai/analysis", requireAuth, async (c) => { - const parsed = await parseBody(c, aiAnalysisRequestSchema, "start, end and duration are required") + const parsed = await parseBody(c, aiAnalysisRequestSchema, "period is required") if (!parsed.success) return parsed.response - if (Number.isNaN(Date.parse(parsed.data.start)) || Number.isNaN(Date.parse(parsed.data.end))) { - return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps") - } // 传 username 的鉴权走 targetUser:非教师传了也只会拿到自己 const user = await targetUser(c, parsed.data.username) if (!user) return failure(c, 404, "user-not-found", "User not found") const limited = await throttleAi(c) if (limited) return limited // 学情数据一律服务端重算,客户端只说看谁、哪段时间 - // detail 现在只带聚合,逐题明细单独取一页给模型看。顺带把喂进 prompt 的条数 - // 卡在 200 —— 以前是整份 solved 无上限塞进去,题做得多的学生一次调用能顶好几倍 token - const [details, duration, solved] = await Promise.all([ - buildDetail(user, parsed.data.start, parsed.data.end), - buildDuration(user, parsed.data.end, parsed.data.duration), - listSolved(user, parsed.data.start, parsed.data.end, 200, 0), - ]) + const report = await buildReport(user, parsed.data.period, { neighbors: false }) const system = [ "你是中职学校的编程课老师,在给自己班上的一位学生写学习小结,学生会在网站上自己读到。", - "下面是这位学生在判题网站上的做题数据(JSON)。页面上方已经有图表,不用把数字挨个复述一遍。", + "下面是这位学生在判题网站上的做题数据(JSON)。页面上已经按天列出了每道题,不用把数字挨个复述一遍。", "用中文 Markdown 写,总共 300 字以内,分四段,每段一到三句,段首加粗:", - "**这段时间**(做了多少、节奏怎么样,挑一两个最能说明问题的数字,节奏照「分段」里的数说)、", - "**做得好的**(点出具体题目名)、", + "**这段时间**(做了多少、节奏怎么样,挑一两个最能说明问题的数字,节奏照「每天」里的说)、", + "**做得好的**(点出具体题目名,挑一次就对的题;交了好几次才对的不算做得好)、", "**要注意的**(错得最多的是哪种,教一个具体的检查办法:答案错误就自己多想几组输入试,特别是最大最小的情况;", - "编译失败、运行时错误就先读报错信息,找到它指出的那一行和错误名;运行超时就看循环能不能停下来;交了好几次才对的题点出名字)、", - "**下一步**(一两件下节课就能做的事,比如把交了好几次才对的题重做一遍、在题目列表里按知识点找题练)。", + "编译失败、运行时错误就先读报错信息,找到它指出的那一行和错误名;运行超时就看循环能不能停下来;", + "交了好几次才对、还没做对、课上没交的题点出名字)、", + "**下一步**(一两件下节课就能做的事,比如把还没做对的题接着做完、把交了好几次才对的题重做一遍、在题目列表里按知识点找题练)。", "最后另起一段,只写一句鼓励的话,要和这位学生的数据挂钩,不要空口号。", "用「你」称呼学生,不要写名字。说大白话,不要英文和术语,不要「追」「对手」这类拿同学比的说法。", "只用数据里有的数字和题目名,不要编;数据里没有的方面就不写(比如错误次数没按题目分,就别说错在哪类题上)。", "不要表格、标题、代码块、字符画、分割线,不要 emoji。", - "数据说明:等级 S/A/B/C 看的是同一道题你是第几个做对的(前 10% 是 S、前 35% 是 A、前 75% 是 B),", - "说明做得快不快,不说明代码好不好。比赛是老师安排的课堂测验,不要建议学生多参加比赛。", + "数据说明:「课上的题」是班上那天一起做的题,「没交」是班上做了、这位学生一次没交;「自己练的题」是课外自己找来做的。", "如果这段时间一道题都没做对,就简短说说看到的情况,鼓励下节课先把老师布置的题做出来。", ].join("") - const prompt = JSON.stringify( - analysisFacts(parsed.data.duration, details, duration, solved.results), - ) + const prompt = JSON.stringify(analysisFacts(report)) return streamChat(system, prompt, { onComplete: async (analysis) => { // 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的 - // GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到 + // GET /ai/analysis/latest 都是按 user_id 找报告的,记在教师名下学生就永远看不到 await db.insert(schema.aiAnalysis).values({ provider: config.aiProvider, model: config.aiModel, - data: { details, duration, solved: solved.results }, + // period 是 /ai/analysis/latest 按时间段找回这份小结的依据 + data: { period: report.period, report }, systemPrompt: system, - userPrompt: "学习详情与周期数据", + userPrompt: prompt, analysis, createTime: new Date().toISOString(), userId: user.id, diff --git a/apps/api/src/services/learning-stats.ts b/apps/api/src/services/learning-stats.ts index 8387d4c..9478b76 100644 --- a/apps/api/src/services/learning-stats.ts +++ b/apps/api/src/services/learning-stats.ts @@ -1,507 +1,391 @@ /** - * 学情统计:AI 学情分析页(/ai/detail、/ai/duration、/ai/solved)和喂给模型的 - * 数据都从这里算。原来整段放在 routes/ai.ts 里,占了那个文件六成;它们只是查询, - * 不碰 Context,挪出来 route 文件只剩「鉴权 → 取参 → 调这里 → 回包」。 + * 智能分析:页面(GET /ai/report)和喂给 AI 小结的学情数据都从这里算。 + * + * 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」, + * 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。 + * 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts): + * 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。 + * + * 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算 + * (比赛是课堂测验,题目也不在题库里)。 */ import { - type AiDetail, - type DurationData, - type Grade, - type SolvedList, - type SolvedProblem, + FLOWCHART_PASS_GRADES, + STUDENT_ROLES, + type LearningDay, + type LearningItem, + type LearningPeriod, + type LearningReport, } from "@oj2/contract" -import { and, asc, count, countDistinct, eq, gte, inArray, lte, min, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, gte, inArray, isNull } from "drizzle-orm" +import { sql } from "drizzle-orm" -import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { JudgeStatus, type JudgeStatusValue } from "../judge/status" -import { rounded } from "../routes/helpers" -import { localTime, shiftMonthsByCalendar } from "../time" +import { calendarDay, monthStart, termStart, weekStart } from "../time" export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] -const difficultyNames: Record = { - Low: "简单", - Mid: "中等", - High: "困难", + +/** 同 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 } -function grade(rank: number | null, count: number, reference = count): Grade { - if (!rank || count <= 0) return "C" - const percentile = ((rank - 1) / count) * 100 - let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C" - if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value - return value +interface Row { + userId: number + problemId: number + time: number + solved: boolean + /** 代码提交的判题状态;流程图为 null */ + result: number | null } -function averageGrade(grades: Grade[]): Grade { - const weights: Record = { S: 4, A: 3, B: 2, C: 1 } - const values = grades.flatMap((item) => weights[item] ?? []) - if (!values.length) return "" - const average = values.reduce((sum, value) => sum + value, 0) / values.length - return average >= 3.5 ? "S" : average >= 2.5 ? "A" : average >= 1.5 ? "B" : "C" -} - -type FirstAcRow = { problemId: number; first: string | null } - -/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */ -function firstAcQuery(user: AuthUser, start: string, end: string, limit?: number, offset?: number) { - const first = min(schema.submission.createTime) - const query = db - .select({ problemId: schema.submission.problemId, first }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - inArray(schema.submission.result, accepted), - gte(schema.submission.createTime, start), - lte(schema.submission.createTime, end), - ), - ) - .groupBy(schema.submission.problemId) - .orderBy(asc(first)) - return limit === undefined ? query : query.limit(limit).offset(offset ?? 0) -} - -/** - * 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。 - * 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。 - */ -export async function buildSolved( - user: AuthUser, - start: string, - end: string, - firstAc: FirstAcRow[], -) { - const problemIds = firstAc.map((item) => item.problemId) - if (!problemIds.length) - return { - solved: [], - problems: [] as { - problem: typeof schema.problem.$inferSelect - contestTitle: string | null - }[], - scopeIds: null as number[] | null, - } - const classUsers = user.className - ? await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(eq(schema.user.className, user.className)) - : [] - const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null - const [problems, rankRows, periodRows, attemptRows] = await Promise.all([ - db - .select({ problem: schema.problem, contestTitle: schema.contest.title }) - .from(schema.problem) - .leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id)) - .where(inArray(schema.problem.id, problemIds)), +/** 代码提交(不含比赛)和流程图提交并成一张,按时间先后 */ +async function activity(userIds: number[], start: string | null): Promise { + const epoch = (column: typeof schema.submission.createTime) => + sql`extract(epoch from ${column}) * 1000`.mapWith(Number) + const [code, flow] = await Promise.all([ db .select({ userId: schema.submission.userId, problemId: schema.submission.problemId, - first: min(schema.submission.createTime), + time: epoch(schema.submission.createTime), + result: schema.submission.result, }) .from(schema.submission) .where( and( - inArray(schema.submission.result, accepted), - inArray(schema.submission.problemId, problemIds), - scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined, - ), - ) - .groupBy(schema.submission.userId, schema.submission.problemId), - db - .select({ - userId: schema.submission.userId, - problemId: schema.submission.problemId, - first: min(schema.submission.createTime), - }) - .from(schema.submission) - .where( - and( - inArray(schema.submission.result, accepted), - inArray(schema.submission.problemId, problemIds), - gte(schema.submission.createTime, start), - lte(schema.submission.createTime, end), - scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined, - ), - ) - .groupBy(schema.submission.userId, schema.submission.problemId), - db - .select({ - problemId: schema.submission.problemId, - time: schema.submission.createTime, - }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - inArray(schema.submission.problemId, problemIds), - gte(schema.submission.createTime, start), - lte(schema.submission.createTime, end), + inArray(schema.submission.userId, userIds), + isNull(schema.submission.contestId), + start ? gte(schema.submission.createTime, start) : undefined, ), ), - ]) - const byProblem = new Map(problems.map((item) => [item.problem.id, item])) - // 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交 - const firstAcTime = new Map( - firstAc.flatMap((item) => - item.first ? ([[item.problemId, Date.parse(item.first)]] as const) : [], - ), - ) - const attemptsByProblem = new Map() - for (const row of attemptRows) { - const deadline = firstAcTime.get(row.problemId) - if (deadline === undefined || Date.parse(row.time) > deadline) continue - attemptsByProblem.set(row.problemId, (attemptsByProblem.get(row.problemId) ?? 0) + 1) - } - function ranks(rows: typeof rankRows, problemId: number) { - return rows - .filter((item) => item.problemId === problemId) - .sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId) - } - const solved = firstAc - .flatMap((item) => { - const problem = byProblem.get(item.problemId) - if (!problem || !item.first) return [] - const all = ranks(rankRows, item.problemId) - const period = ranks(periodRows, item.problemId) - const rank = all.findIndex((row) => row.userId === user.id) + 1 || null - const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null - return { - problem: { - title: problem.problem.title, - displayId: problem.problem.displayId, - contestTitle: problem.contestTitle ?? "", - contestId: problem.problem.contestId, - }, - acTime: item.first, - rank, - acCount: all.length, - grade: grade(periodRank, period.length, all.length), - periodRank, - periodAcCount: period.length, - difficulty: difficultyNames[problem.problem.difficulty] ?? "中等", - attempts: attemptsByProblem.get(item.problemId) ?? 1, - } satisfies SolvedProblem - }) - .sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime)) - return { solved, problems, scopeIds } -} - -/** 分页版:只算这一页的题 */ -export async function listSolved( - user: AuthUser, - start: string, - end: string, - limit: number, - offset: number, -) { - const [firstAc, totalRows] = await Promise.all([ - firstAcQuery(user, start, end, limit, offset), db - .select({ value: countDistinct(schema.submission.problemId) }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - inArray(schema.submission.result, accepted), - gte(schema.submission.createTime, start), - lte(schema.submission.createTime, end), + .select({ + userId: schema.flowchartSubmission.userId, + problemId: schema.flowchartSubmission.problemId, + time: sql`extract(epoch from ${schema.flowchartSubmission.createTime}) * 1000`.mapWith( + Number, ), - ), - ]) - const { solved } = await buildSolved(user, start, end, firstAc) - return { - results: solved, - total: totalRows[0]?.value ?? 0, - } satisfies SolvedList -} - -export async function buildDetail(user: AuthUser, start: string, end: string) { - // 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次 - // 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。 - // 星期和小时都按东八区取,和热力图同口径 - const weekday = - sql`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number) - const period = - sql`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith( - Number, - ) - const activityRows = await db - .select({ weekday, period, value: count() }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - gte(schema.submission.createTime, start), - lte(schema.submission.createTime, end), - ), - ) - .groupBy(weekday, period) - const activity = activityRows.map((row) => ({ - weekday: row.weekday, - period: row.period, - count: row.value, - })) - // 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。 - // 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义 - const submissions = await db - .select({ - problemId: schema.submission.problemId, - time: schema.submission.createTime, - result: schema.submission.result, - }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - gte(schema.submission.createTime, start), - lte(schema.submission.createTime, end), - ), - ) - const settledFail = (result: JudgeStatusValue) => - !accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING - const errorCounts = new Map() - for (const row of submissions) { - if (!settledFail(row.result)) continue - errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1) - } - const errors = [...errorCounts] - .map(([result, count]) => ({ result, count })) - .sort((a, b) => b.count - a.count || a.result - b.result) - const firstAc = await firstAcQuery(user, start, end) - const problemIds = firstAc.map((item) => item.problemId) - if (!problemIds.length) - return { - username: user.username, - className: user.className, - start, - end, - solvedCount: 0, - attempts: [], - flowcharts: [], - grade: "", - tags: {}, - difficulty: {}, - contestCount: 0, - activity, - errors, - rankScope: "global", - } satisfies AiDetail - const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([ - buildSolved(user, start, end, firstAc), - db - .select({ - problemId: schema.problemTags.problemId, - name: schema.problemTag.name, - }) - .from(schema.problemTags) - .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) - .where(inArray(schema.problemTags.problemId, problemIds)), - db - .select({ - flow: schema.flowchartSubmission, - displayId: schema.problem.displayId, - title: schema.problem.title, + status: schema.flowchartSubmission.status, + grade: schema.flowchartSubmission.aiGrade, }) .from(schema.flowchartSubmission) - .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .where( and( - eq(schema.flowchartSubmission.userId, user.id), - eq(schema.flowchartSubmission.status, 2), - gte(schema.flowchartSubmission.createTime, start), - lte(schema.flowchartSubmission.createTime, end), + inArray(schema.flowchartSubmission.userId, userIds), + start ? gte(schema.flowchartSubmission.createTime, start) : undefined, ), ), ]) - const tags: Record = {} - for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1 - const topTags = Object.fromEntries( - Object.entries(tags) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5), - ) - const difficulty: Record = { 简单: 0, 中等: 0, 困难: 0 } - for (const item of problems) { - const name = difficultyNames[item.problem.difficulty] ?? "中等" - difficulty[name] = (difficulty[name] ?? 0) + 1 - } - const flowGroups = new Map() - for (const flow of flowRows) - flowGroups.set(flow.displayId, [...(flowGroups.get(flow.displayId) ?? []), flow]) - const flowcharts = [...flowGroups] - .map(([displayId, rows]) => { - const scores = rows.flatMap((row) => row.flow.aiScore ?? []) - // 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 —— - // ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上 - const top = rows.reduce( - (best, row) => ((row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best), - rows[0]!, - ) - return { - problemId: displayId, - problemTitle: rows[0]?.title ?? "", - submissionCount: rows.length, - bestScore: Math.max(0, top.flow.aiScore ?? 0), - bestGrade: top.flow.aiGrade ?? "", - latestSubmissionTime: - rows - .map((row) => row.flow.createTime) - .sort() - .at(-1) ?? start, - avgScore: rounded( - scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, - 0, - ), - } - }) - .sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime)) - return { - username: user.username, - className: user.className, - start, - end, - flowcharts, - solvedCount: solved.length, - attempts: solved.map((item) => item.attempts), - grade: averageGrade(solved.map((item) => item.grade)), - tags: topTags, - difficulty, - contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size, - activity, - errors, - rankScope: scopeIds ? "class" : "global", - } satisfies AiDetail + 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) } -export async function buildDuration(user: AuthUser, endText: string, duration: string) { - const config = - duration === "months:2" - ? { - count: 8, - unit: "weeks", - rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5), - advance: (date: Date) => new Date(date.getTime() + 7 * 864e5), - } - : duration === "months:6" - ? { - count: 6, - unit: "months", - rewind: (date: Date) => shiftMonthsByCalendar(date, -7), - advance: (date: Date) => shiftMonthsByCalendar(date, 1), - } - : duration === "years:1" - ? { - count: 12, - unit: "months", - rewind: (date: Date) => shiftMonthsByCalendar(date, -13), - advance: (date: Date) => shiftMonthsByCalendar(date, 1), - } - : { - count: 4, - unit: "weeks", - rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5), - advance: (date: Date) => new Date(date.getTime() + 7 * 864e5), - } - // 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。 - // 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。 - // 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交 - // 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。 - let cursor = config.rewind(new Date(endText)) - const buckets: { start: Date; end: Date }[] = [] - for (let index = 0; index < config.count; index++) { - const start = config.advance(cursor) - buckets.push({ start, end: config.advance(start) }) - cursor = start - } - // 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价, - // 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响 - const rows = await db - .select({ - time: sql`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(Number), - problemId: schema.submission.problemId, - result: schema.submission.result, - }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - gte(schema.submission.createTime, buckets[0]!.start.toISOString()), - lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()), - ), - ) - // 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里 - // 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里 - // 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。 - // 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。 - const solvedIds = [ - ...new Set(rows.filter((row) => accepted.includes(row.result)).map((row) => row.problemId)), - ] - const classUsers = user.className +/** 一个人在一道题上:第一次交的时刻、做对的时刻、到做对为止交了几次、没做对时最后一次的结果 */ +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 { + const now = new Date() + const start = periodStart(period, now) + const members = target.className ? await db - .select({ id: schema.user.id }) + .select({ id: schema.user.id, username: schema.user.username }) .from(schema.user) - .where(eq(schema.user.className, user.className)) - : [] - const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null - const peers = solvedIds.length - ? await db - .select({ - time: sql`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith( - Number, - ), - userId: schema.submission.userId, - problemId: schema.submission.problemId, - }) - .from(schema.submission) .where( and( - inArray(schema.submission.result, accepted), - inArray(schema.submission.problemId, solvedIds), - gte(schema.submission.createTime, buckets[0]!.start.toISOString()), - lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()), - scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined, + eq(schema.user.className, target.className), + eq(schema.user.isDisabled, false), + inArray(schema.user.adminType, [...STUDENT_ROLES]), ), ) + .orderBy(asc(schema.user.username)) : [] - // 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题 - const peersByProblem = new Map() - for (const row of peers) - peersByProblem.set(row.problemId, [...(peersByProblem.get(row.problemId) ?? []), row]) + 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, + ), + ) + : [], + ]) - function bucketGrade(problemIds: number[], from: number, to: number) { - return averageGrade( - problemIds.map((problemId) => { - const firstAc = new Map() - for (const row of peersByProblem.get(problemId) ?? []) { - if (row.time < from || row.time > to) continue - const seen = firstAc.get(row.userId) - if (seen === undefined || row.time < seen) firstAc.set(row.userId, row.time) - } - const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0]) - const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null - return grade(rank, ordered.length) - }), + // 每人每题的轨迹,和同班「某天某题」的人数、最早时刻(推断这节课、排题目顺序用) + const tracks = new Map>() + const dayUsers = new Map>() + const dayFirst = new Map() + 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() + const planOrder = new Map() + 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) } - return buckets.map((bucket, index) => { - const from = bucket.start.getTime() - const to = bucket.end.getTime() - const inRange = rows.filter((row) => row.time >= from && row.time <= to) - const acceptedRows = inRange.filter((row) => accepted.includes(row.result)) - const solved = [...new Set(acceptedRows.map((row) => row.problemId))] + const myTracks = tracks.get(target.id) ?? new Map() + 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 { - unit: config.unit, - index: config.count - 1 - index, - start: bucket.start.toISOString(), - end: bucket.end.toISOString(), - grade: solved.length ? bucketGrade(solved, from, to) : "", - problemCount: solved.length, - acceptedCount: acceptedRows.length, - submissionCount: inRange.length, - } satisfies DurationData - }) + 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() + 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() + 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() + 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 } diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index 8fe0dc5..850e47b 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -95,6 +95,14 @@ export function termStart(now: Date | number | string = new Date()): string { return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString() } +/** 「东八区本月 1 日」的零点,返回 ISO 字符串 */ +export function monthStart(now: Date | number | string = new Date()): string { + const wall = toWallClock(now) + return fromWallClock( + new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth(), 1)), + ).toISOString() +} + /** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */ export function shiftMonthsByCalendar(instant: Date, months: number): Date { const wall = toWallClock(instant) diff --git a/packages/contract/src/ai.ts b/packages/contract/src/ai.ts index 0155f48..5f4822f 100644 --- a/packages/contract/src/ai.ts +++ b/packages/contract/src/ai.ts @@ -3,114 +3,97 @@ import { z } from "zod" import { classPkPeriodSchema } from "./ranking" /** - * 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 —— - * 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。 + * 智能分析看哪段时间。全按东八区日历切(同排名页):这周 = 本周一零点起,这个月 = 本月 1 日 + * 零点起,这学期 = `termStart()`(9 月 1 日 / 2 月 1 日),全部 = 不设下限。 */ -export const gradeSchema = z.enum(["S", "A", "B", "C", ""]) +export const learningPeriodSchema = z.enum(["week", "month", "term", "all"]) -export const durationDataSchema = z.object({ - unit: z.string(), - index: z.number().int(), - start: z.string(), - end: z.string(), - grade: gradeSchema, - problemCount: z.number().int(), - /** 该周期内判为通过的提交数。problemCount 是去重后的题数,两者不能互相代替 */ - acceptedCount: z.number().int(), - submissionCount: z.number().int(), -}) +export const LEARNING_PERIOD_LABELS = { + week: "这周", + month: "这个月", + term: "这学期", + all: "全部", +} as const satisfies Record, string> -export const solvedProblemSchema = z.object({ - problem: z.object({ - title: z.string(), - displayId: z.string(), - contestTitle: z.string(), - contestId: z.number().int().nullable(), - }), - acTime: z.string(), - rank: z.number().int().nullable(), - acCount: z.number().int(), - grade: gradeSchema, - periodRank: z.number().int().nullable(), - periodAcCount: z.number().int(), - difficulty: z.string(), - /** 到首次通过为止在这道题上提交了几次(含通过那次)。1 就是一次过 */ +/** + * 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂看板:代码 AC / + * AST_CHECK_FAILED、流程图评到 A / S 都算做对。 + * + * - `first`:第一次交就对了 + * - `tries`:交了 `attempts` 次才对(含做对那次) + * - `fail`:交了 `attempts` 次,还没做对 + * - `none`:班上这节课做了,这个学生一次没交 + */ +export const learningItemSchema = z.object({ + displayId: z.string(), + title: z.string(), + status: z.enum(["first", "tries", "fail", "none"]), attempts: z.number().int(), -}) - -export const flowchartSummarySchema = z.object({ - problemId: z.string(), - problemTitle: z.string(), - submissionCount: z.number().int(), - bestScore: z.number(), - bestGrade: z.string(), - latestSubmissionTime: z.string(), - avgScore: z.number(), + /** 班上第几个做对(只数这段时间里做对的同班同学);没做对、没有班级时为 null */ + rank: z.number().int().nullable(), + /** 这段时间班上几人做对;没有班级时为 null */ + classSolved: z.number().int().nullable(), + /** 没做对时最后一次代码提交的判题状态;只交过流程图时为 null */ + lastResult: z.number().int().nullable(), }) /** - * 时间活跃度的一个格子。星期和时段都按东八区切,和热力图同一个口径 —— - * 别让容器或数据库的 TZ 决定「学生周几晚上做题多」。 + * 一天一行。`lesson` 是班上这节课的题:老师在课堂看板布置过就用布置的,否则同班同一天 + * ≥ 5 人做过的题(同「班里在做」的推断);一道题只归到一天。`own` 是这个学生这天自己练的、 + * 不属于任何一节课的题,按他第一次交的那天归。 */ -export const activityBucketSchema = z.object({ - /** 0=周日 … 6=周六 */ - weekday: z.number().int().min(0).max(6), - /** 0=凌晨(0-6) 1=上午(6-12) 2=下午(12-18) 3=晚上(18-24) */ - period: z.number().int().min(0).max(3), - count: z.number().int(), +export const learningDaySchema = z.object({ + /** 东八区日历日 `YYYY-MM-DD` */ + day: z.string(), + lesson: z.array(learningItemSchema), + own: z.array(learningItemSchema), }) -export const aiDetailSchema = z.object({ +export const learningReportSchema = z.object({ username: z.string(), className: z.string().nullable(), - start: z.string(), + period: learningPeriodSchema, + start: z.string().nullable(), end: z.string(), + solved: z.number().int(), + firstTry: z.number().int(), + /** 这段时间班上上了几节课(有 `lesson` 的天数) */ + lessonCount: z.number().int(), + /** 代码 + 流程图提交次数 */ + submissionCount: z.number().int(), + /** 交过还没做对的题,交得多的在前 */ + unsolved: z.array(learningItemSchema), /** - * 区间内做出来的题数。逐题明细走 `GET /ai/solved` 分页拿 —— - * 一个活跃学生一年能做几百道,整份塞进这个响应没有必要。 + * 和班上比。「班上」= 这段时间交过题的同班学生(同排名口径的学生角色),没有班级时为 null。 + * `rank` 是按做对题数排第几(比他多的人数 + 1)。 */ - solvedCount: z.number().int(), - /** 每道做出来的题「到首次通过为止提交了几次」,分档放在前端 */ - attempts: z.array(z.number().int()), - flowcharts: z.array(flowchartSummarySchema), - grade: gradeSchema, - tags: z.record(z.string(), z.number().int()), - difficulty: z.record(z.string(), z.number().int()), - contestCount: z.number().int(), - /** 时间活跃度:按**全部提交**统计,不是只统计 AC */ - activity: z.array(activityBucketSchema), - /** - * 判完的失败提交按状态码分组,多的在前。状态码是落库的值, - * 前端用 utils/constants 的 JUDGE_STATUS 翻成中文,两边必须一致。 - */ - errors: z.array( - z.object({ - result: z.number().int(), - count: z.number().int(), - }), + classStats: z + .object({ + size: z.number().int(), + medianSolved: z.number(), + medianFirstPercent: z.number().int(), + rank: z.number().int(), + }) + .nullable(), + /** 新的在前 */ + days: z.array(learningDaySchema), + /** 没通过的代码提交按判题状态分组,多的在前;系统错误、还没判完的不算 */ + errors: z.array(z.object({ result: z.number().int(), count: z.number().int() })), + /** 全部知识点(tag 的 knowledge 类),按题库里题数从多到少;没碰过的 solved = tried = 0 */ + knowledge: z.array( + z.object({ name: z.string(), solved: z.number().int(), tried: z.number().int() }), ), - /** - * solved 里的 rank/acCount 是在哪个范围里排的。班里只有一个人时后端会回退到全服, - * 前端不能只看 className 有没有值就写「班级排名」。 - */ - rankScope: z.enum(["class", "global"]), + /** 老师看学生时,同班按用户名排的前一个 / 后一个;学生看自己时为 null */ + neighbors: z.object({ prev: z.string().nullable(), next: z.string().nullable() }).nullable(), }) /** - * 请求只说「看谁、哪段时间、按什么粒度」,学情数据由服务端自己算。 + * 请求只说「看谁、哪段时间」,学情数据由服务端自己算。 * 以前是 `details: z.unknown()` / `duration: z.unknown()` —— 前端算好的整包 POST 回去, * 原样进 prompt 又原样写进 ai_analysis 表,等于让任何登录用户决定喂给模型什么。 */ -/** GET /ai/solved 的分页响应 */ -export const solvedListSchema = z.object({ - results: z.array(solvedProblemSchema), - total: z.number().int(), -}) - export const aiAnalysisRequestSchema = z.object({ - start: z.string().min(1), - end: z.string().min(1), - duration: z.string().min(1), + period: learningPeriodSchema, username: z.string().optional(), }) @@ -248,17 +231,6 @@ export const classPkAnalysisRequestSchema = z.object({ period: classPkPeriodSchema, }) -/** - * 热力图的一格 = **一周**(不是一天)。timestamp 是那一周(按东八区日历)周一的 UTC 零点, - * 前端按东八区取年月日(`zonedParts`),不要用浏览器本地部件。 - * value 是整周的提交次数。按天切的话一年 365 格里三百多格是空的, - * 中职学生一年也就在二三十天有提交,整张图看着像没用过。 - */ -export const heatmapItemSchema = z.object({ - timestamp: z.number(), - value: z.number().int(), -}) - export const aiAnalysisRecordSchema = z.object({ id: z.number().int(), provider: z.string(), @@ -270,14 +242,10 @@ export const aiAnalysisRecordSchema = z.object({ username: z.string().optional(), }) -export type Grade = z.infer -export type DurationData = z.infer -export type SolvedProblem = z.infer -export type FlowchartSummary = z.infer -export type ActivityBucket = z.infer -export type AiDetail = z.infer -export type SolvedList = z.infer -export type HeatmapItem = z.infer +export type LearningPeriod = z.infer +export type LearningItem = z.infer +export type LearningDay = z.infer +export type LearningReport = z.infer export type AiAnalysisRecord = z.infer export type AiAnalysisRequest = z.infer