feat(智能分析): 后端改成按「每节课」给数据,AI 小结跟着改,不再把交了好几次的题夸成做得好
GET /ai/report 一次给整页:每天分课上的题和自己练的,和班上中间那位比,差一点的、错在哪、 知识点;时间段改成 这周 / 这个月 / 这学期 / 全部。删了 /ai/detail、/ai/solved、 /ai/duration、/ai/heatmap、/ai/pinned,换成 /ai/analysis/latest。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
69f8d89153
commit
890e731a1c
4 files changed
+507
-721
No files matched your search
+83
-157
@@ -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<AppEnv>()
|
||||
@@ -68,83 +68,41 @@ async function targetUser(c: Context<AppEnv>, 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<string>`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<string, string> = {
|
||||
"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<string, number>()
|
||||
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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, number> = { 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<Row[]> {
|
||||
const epoch = (column: typeof schema.submission.createTime) =>
|
||||
sql<number>`extract(epoch from ${column}) * 1000`.mapWith(Number)
|
||||
const [code, flow] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
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<number, number>()
|
||||
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<number>`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<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number)
|
||||
const period =
|
||||
sql<number>`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<number, number>()
|
||||
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<string, number> = {}
|
||||
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<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
|
||||
for (const item of problems) {
|
||||
const name = difficultyNames[item.problem.difficulty] ?? "中等"
|
||||
difficulty[name] = (difficulty[name] ?? 0) + 1
|
||||
}
|
||||
const flowGroups = new Map<string, typeof flowRows>()
|
||||
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<number>`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<LearningReport> {
|
||||
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<number>`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<number, typeof peers>()
|
||||
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<number, number>()
|
||||
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<number, Map<number, Track>>()
|
||||
const dayUsers = new Map<string, Set<number>>()
|
||||
const dayFirst = new Map<string, number>()
|
||||
const mine: Row[] = []
|
||||
for (const row of rows) {
|
||||
if (row.userId === target.id) mine.push(row)
|
||||
let byProblem = tracks.get(row.userId)
|
||||
if (!byProblem) tracks.set(row.userId, (byProblem = new Map()))
|
||||
let track = byProblem.get(row.problemId)
|
||||
if (!track)
|
||||
byProblem.set(
|
||||
row.problemId,
|
||||
(track = { first: row.time, solvedAt: null, attempts: 0, last: null }),
|
||||
)
|
||||
if (track.solvedAt === null) {
|
||||
track.attempts += 1
|
||||
if (row.solved) track.solvedAt = row.time
|
||||
else if (row.result !== null) track.last = row.result
|
||||
}
|
||||
if (!memberIds.has(row.userId)) continue
|
||||
const key = `${calendarDay(row.time)}|${row.problemId}`
|
||||
let users = dayUsers.get(key)
|
||||
if (!users) dayUsers.set(key, (users = new Set()))
|
||||
users.add(row.userId)
|
||||
if (!dayFirst.has(key)) dayFirst.set(key, row.time)
|
||||
}
|
||||
const usersOn = (day: string, problemId: number) => dayUsers.get(`${day}|${problemId}`)?.size ?? 0
|
||||
|
||||
// 一道题只归到一天:老师布置过的归布置的那天(布置了好几天就挑人最多的),
|
||||
// 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的
|
||||
const lessonDay = new Map<number, string>()
|
||||
const planOrder = new Map<string, number>()
|
||||
for (const plan of plans) {
|
||||
plan.problemIds.forEach((problemId, index) => {
|
||||
planOrder.set(`${plan.day}|${problemId}`, index)
|
||||
const current = lessonDay.get(problemId)
|
||||
if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId))
|
||||
lessonDay.set(problemId, plan.day)
|
||||
})
|
||||
}
|
||||
const planned = new Set(lessonDay.keys())
|
||||
for (const [key, users] of dayUsers) {
|
||||
if (users.size < LESSON_MIN_USERS) continue
|
||||
const [day, id] = key.split("|") as [string, string]
|
||||
const problemId = Number(id)
|
||||
if (planned.has(problemId)) continue
|
||||
const current = lessonDay.get(problemId)
|
||||
if (
|
||||
!current ||
|
||||
users.size > usersOn(current, problemId) ||
|
||||
(users.size === usersOn(current, problemId) && day > current)
|
||||
)
|
||||
lessonDay.set(problemId, day)
|
||||
}
|
||||
|
||||
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<number, Track>()
|
||||
const problemIds = [...new Set([...lessonDay.keys(), ...myTracks.keys()])]
|
||||
const [problems, knowledgeTags, myTags] = await Promise.all([
|
||||
problemIds.length
|
||||
? db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
visible: schema.problem.visible,
|
||||
contestId: schema.problem.contestId,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(inArray(schema.problem.id, problemIds))
|
||||
: [],
|
||||
db
|
||||
.select({ name: schema.problemTag.name, total: count() })
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.problemTags.problemId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemTag.category, "knowledge"),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.problemTag.name)
|
||||
.orderBy(desc(count()), asc(schema.problemTag.name)),
|
||||
myTracks.size
|
||||
? db
|
||||
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemTag.category, "knowledge"),
|
||||
inArray(schema.problemTags.problemId, [...myTracks.keys()]),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
])
|
||||
const problemById = new Map(problems.map((item) => [item.id, item]))
|
||||
// 被藏起来的题、比赛题不当「这节课的题」—— 学生点进去是 404
|
||||
for (const problemId of lessonDay.keys()) {
|
||||
const problem = problemById.get(problemId)
|
||||
if (!problem?.visible || problem.contestId !== null) lessonDay.delete(problemId)
|
||||
}
|
||||
|
||||
function item(problemId: number): LearningItem {
|
||||
const problem = problemById.get(problemId)
|
||||
const track = myTracks.get(problemId)
|
||||
let rank: number | null = null
|
||||
let classSolved: number | null = null
|
||||
if (memberIds.size) {
|
||||
const solvedAt: [number, number][] = []
|
||||
for (const id of memberIds) {
|
||||
const at = tracks.get(id)?.get(problemId)?.solvedAt
|
||||
if (at != null) solvedAt.push([at, id])
|
||||
}
|
||||
solvedAt.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
classSolved = solvedAt.length
|
||||
const index = solvedAt.findIndex(([, id]) => id === target.id)
|
||||
rank = index >= 0 ? index + 1 : null
|
||||
}
|
||||
const status = !track
|
||||
? "none"
|
||||
: track.solvedAt === null
|
||||
? "fail"
|
||||
: track.attempts === 1
|
||||
? "first"
|
||||
: "tries"
|
||||
return {
|
||||
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<string, { lesson: number[]; own: number[] }>()
|
||||
const slot = (day: string) => {
|
||||
let entry = byDay.get(day)
|
||||
if (!entry) byDay.set(day, (entry = { lesson: [], own: [] }))
|
||||
return entry
|
||||
}
|
||||
for (const [problemId, day] of lessonDay) slot(day).lesson.push(problemId)
|
||||
for (const [problemId, track] of myTracks)
|
||||
if (!lessonDay.has(problemId)) slot(calendarDay(track.first)).own.push(problemId)
|
||||
const order = (day: string, problemId: number) =>
|
||||
planOrder.get(`${day}|${problemId}`) ?? 1e6 + (dayFirst.get(`${day}|${problemId}`) ?? 0) / 1e3
|
||||
const days: LearningDay[] = [...byDay]
|
||||
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
||||
.map(([day, entry]) => ({
|
||||
day,
|
||||
lesson: entry.lesson.sort((a, b) => order(day, a) - order(day, b)).map(item),
|
||||
own: entry.own.sort((a, b) => myTracks.get(a)!.first - myTracks.get(b)!.first).map(item),
|
||||
}))
|
||||
|
||||
const solvedIds = [...myTracks].filter(([, track]) => track.solvedAt !== null)
|
||||
const solved = solvedIds.length
|
||||
let classStats: LearningReport["classStats"] = null
|
||||
if (memberIds.size) {
|
||||
const counts: number[] = []
|
||||
const firstPercents: number[] = []
|
||||
for (const id of memberIds) {
|
||||
const byProblem = tracks.get(id)
|
||||
if (!byProblem) continue
|
||||
let done = 0
|
||||
let first = 0
|
||||
for (const track of byProblem.values()) {
|
||||
if (track.solvedAt === null) continue
|
||||
done += 1
|
||||
if (track.attempts === 1) first += 1
|
||||
}
|
||||
counts.push(done)
|
||||
if (done) firstPercents.push((first / done) * 100)
|
||||
}
|
||||
if (counts.length)
|
||||
classStats = {
|
||||
size: counts.length,
|
||||
medianSolved: median(counts),
|
||||
medianFirstPercent: Math.round(median(firstPercents)),
|
||||
rank: 1 + counts.filter((value) => value > solved).length,
|
||||
}
|
||||
}
|
||||
|
||||
const errorCounts = new Map<number, number>()
|
||||
for (const row of mine) {
|
||||
if (row.solved || row.result === null || NOT_STUDENT_ERRORS.includes(row.result)) continue
|
||||
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
|
||||
}
|
||||
const tagCounts = new Map<string, { solved: number; tried: number }>()
|
||||
for (const tag of myTags) {
|
||||
const entry = tagCounts.get(tag.name) ?? { solved: 0, tried: 0 }
|
||||
entry.tried += 1
|
||||
if (myTracks.get(tag.problemId)?.solvedAt != null) entry.solved += 1
|
||||
tagCounts.set(tag.name, entry)
|
||||
}
|
||||
|
||||
let neighbors: LearningReport["neighbors"] = null
|
||||
if (options.neighbors && members.length) {
|
||||
const index = members.findIndex((item) => item.id === target.id)
|
||||
if (index >= 0)
|
||||
neighbors = {
|
||||
prev: members[index - 1]?.username ?? null,
|
||||
next: members[index + 1]?.username ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
username: target.username,
|
||||
className: target.className,
|
||||
period,
|
||||
start,
|
||||
end: now.toISOString(),
|
||||
solved,
|
||||
firstTry: solvedIds.filter(([, track]) => track.attempts === 1).length,
|
||||
lessonCount: days.filter((day) => day.lesson.length).length,
|
||||
submissionCount: mine.length,
|
||||
unsolved: [...myTracks]
|
||||
.filter(([, track]) => track.solvedAt === null)
|
||||
.sort((a, b) => b[1].attempts - a[1].attempts)
|
||||
.map(([problemId]) => item(problemId)),
|
||||
classStats,
|
||||
days,
|
||||
errors: [...errorCounts]
|
||||
.map(([result, value]) => ({ result, count: value }))
|
||||
.sort((a, b) => b.count - a.count || a.result - b.result),
|
||||
knowledge: knowledgeTags.map((tag) => ({
|
||||
name: tag.name,
|
||||
...(tagCounts.get(tag.name) ?? { solved: 0, tried: 0 }),
|
||||
})),
|
||||
neighbors,
|
||||
} satisfies LearningReport
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+72
-104
@@ -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<z.infer<typeof learningPeriodSchema>, 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<typeof gradeSchema>
|
||||
export type DurationData = z.infer<typeof durationDataSchema>
|
||||
export type SolvedProblem = z.infer<typeof solvedProblemSchema>
|
||||
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema>
|
||||
export type ActivityBucket = z.infer<typeof activityBucketSchema>
|
||||
export type AiDetail = z.infer<typeof aiDetailSchema>
|
||||
export type SolvedList = z.infer<typeof solvedListSchema>
|
||||
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
|
||||
export type LearningPeriod = z.infer<typeof learningPeriodSchema>
|
||||
export type LearningItem = z.infer<typeof learningItemSchema>
|
||||
export type LearningDay = z.infer<typeof learningDaySchema>
|
||||
export type LearningReport = z.infer<typeof learningReportSchema>
|
||||
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
||||
|
||||
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
|
||||
|
||||
Reference in new issue
Block a user