- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比; 显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算) - 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班, 一样多挑班级对抗排得更前的 - 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算) - /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { z } from "zod"
|
||
|
||
import { classPkPeriodSchema } from "./ranking"
|
||
|
||
/**
|
||
* 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 ——
|
||
* 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。
|
||
*/
|
||
export const gradeSchema = z.enum(["S", "A", "B", "C", ""])
|
||
|
||
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 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 就是一次过 */
|
||
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(),
|
||
})
|
||
|
||
/**
|
||
* 时间活跃度的一个格子。星期和时段都按东八区切,和热力图同一个口径 ——
|
||
* 别让容器或数据库的 TZ 决定「学生周几晚上做题多」。
|
||
*/
|
||
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 aiDetailSchema = z.object({
|
||
username: z.string(),
|
||
className: z.string().nullable(),
|
||
start: z.string(),
|
||
end: z.string(),
|
||
/**
|
||
* 区间内做出来的题数。逐题明细走 `GET /ai/solved` 分页拿 ——
|
||
* 一个活跃学生一年能做几百道,整份塞进这个响应没有必要。
|
||
*/
|
||
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(),
|
||
}),
|
||
),
|
||
/**
|
||
* solved 里的 rank/acCount 是在哪个范围里排的。班里只有一个人时后端会回退到全服,
|
||
* 前端不能只看 className 有没有值就写「班级排名」。
|
||
*/
|
||
rankScope: z.enum(["class", "global"]),
|
||
})
|
||
|
||
/**
|
||
* 请求只说「看谁、哪段时间、按什么粒度」,学情数据由服务端自己算。
|
||
* 以前是 `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),
|
||
username: z.string().optional(),
|
||
})
|
||
|
||
/**
|
||
* 解锁「让 AI 分析我的代码」所需的失败提交数。前端拿它决定按钮露不露面、
|
||
* 后端拿它卡 POST /ai/hint —— 放在契约里就是为了不让两边各写一个数字。
|
||
*
|
||
* 编译失败不受这个门槛限制:报错只关乎语法、不涉及解法,而英文编译报错恰恰是
|
||
* 零基础学生最先撞上、最容易直接放弃的那堵墙。
|
||
*
|
||
* **2c 起从 3 降到 1**(设计 2.6):门槛的活由下面的等级阶梯接走了 —— 第一次失败
|
||
* 只开放 L0,而 L0 只反问、什么都不泄露,拦着它没有意义;再往上每级都要学生自己
|
||
* 点、而且要再交一次,比一刀切的「攒够 3 次」更贴学生卡住的时刻。
|
||
*/
|
||
export const HINT_MIN_FAILURES = 1
|
||
|
||
/**
|
||
* AI 提示的等级(AI 时代 OJ 设计 2.2)。**数字是落库的值(`ai_hint.level`)**,
|
||
* 和判题状态码一样只能新增、不能改已有含义 —— 教师端「依赖提示」这类风险标签
|
||
* 要按它聚合,改含义等于把历史数据一起改了。
|
||
*
|
||
* 0~4 是一条阶梯,只能逐级上升:**一条新的失败提交最多解锁一级**,而且要学生自己
|
||
* 点「再多一点提示」才升,不会自动往上爬(2.6)。所以从第一次失败到 L4 至少要交 5 次。
|
||
*
|
||
* L3 / L4 是 2d 加的。**L4 是阶梯上唯一允许出现代码的一级**(至多两行,见
|
||
* `services/hint-filter.ts` 的 `KEY_LINES_MAX`),L0~L3 一行代码都不许有。
|
||
* 老师按班级 / 作业设最高等级还没做,现在所有题都开到 L4。
|
||
*/
|
||
export const HINT_LEVELS = [
|
||
{ level: 0, name: "反问", summary: "只反问,不给结论" },
|
||
{ level: 1, name: "定位", summary: "只说问题在哪,不说为什么" },
|
||
{ level: 2, name: "概念", summary: "讲清涉及的概念,不给改法" },
|
||
{ level: 3, name: "思路", summary: "分步骤讲解题思路,不写代码" },
|
||
{ level: 4, name: "示例", summary: "用相似的例子演示,或给出关键的一行" },
|
||
] as const
|
||
|
||
export const HINT_MAX_LEVEL = 4
|
||
|
||
/**
|
||
* 编译失败的提示走这个值,**不在阶梯上**(查阶梯的 SQL 一律 `level >= 0`)。
|
||
*
|
||
* 编译错误只关乎语法、不涉及解法,给出定位甚至正确片段都不算放水 —— 而英文编译
|
||
* 报错恰恰是零基础学生最先撞上、最容易直接放弃的那堵墙,所以它既不消耗等级、
|
||
* 也不推进等级(同 HINT_MIN_FAILURES 对编译失败的豁免)。
|
||
*
|
||
* `ai_hint.level` 还有第三种值 null:2c 上线之前那批不分级的提示,同样不参与阶梯。
|
||
*/
|
||
export const HINT_LEVEL_COMPILE = -1
|
||
|
||
/** 给界面看的等级名。编译失败那一档不叫 L-1 */
|
||
export function hintLevelLabel(level: number) {
|
||
if (level === HINT_LEVEL_COMPILE) return "编译错误"
|
||
const item = HINT_LEVELS.find((entry) => entry.level === level)
|
||
// 给学生看的,从 1 数起、不写「L0」:学生不知道 L 是什么,也不会从 0 数
|
||
return item ? `第 ${item.level + 1} 档 · ${item.name}` : `第 ${level + 1} 档`
|
||
}
|
||
|
||
/**
|
||
* `more` = 学生点的是「再多一点提示」。**升级只能由学生主动发起**:不带它就按当前
|
||
* 等级再生成一次(换了提交也一样),带上它且距上次升级之后又交过一次才会 +1。
|
||
*/
|
||
export const aiHintRequestSchema = z.object({
|
||
submissionId: z.string().min(1),
|
||
more: z.boolean().optional(),
|
||
})
|
||
|
||
/**
|
||
* `/ai/hint` 流末尾 `done` 事件的载荷。`hintId` 落库失败时为 null(前端据此不出评价
|
||
* 按钮),`canEscalate` 是「现在再点一次『再多一点提示』能不能升级」—— 前端拿它决定
|
||
* 那个按钮出不出,而不是自己去推算阶梯。
|
||
*/
|
||
export const aiHintDoneSchema = z.object({
|
||
hintId: z.number().int().nullable(),
|
||
level: z.number().int(),
|
||
canEscalate: z.boolean(),
|
||
})
|
||
|
||
/**
|
||
* AI 提示第一段「诊断」给错误归的类。**key 是落库的值(`ai_hint.diagnosis.tag`),
|
||
* 和判题状态码一样只能新增、不能改已有 key 的含义** —— 教师端的学情统计要按它聚合。
|
||
* `label` 只是给人看的说明,可以改措辞。
|
||
*
|
||
* 口径按中职入门的 C / Python 定的。`output_format` 刻意写细:多余的输入提示语、
|
||
* 全角冒号、多一个空格、小数位数,是这批学生最常见、也最冤的一类 WA。
|
||
*/
|
||
export const HINT_ERROR_TAGS = {
|
||
syntax: "语法错误",
|
||
input_format: "输入读取方式不对(格式、分隔、个数)",
|
||
output_format: "输出格式不对(多余的输入提示语、全角/半角符号、多余空格或换行、小数位数)",
|
||
condition: "条件判断写错(比较符、漏了分支)",
|
||
loop_bound: "循环次数或边界不对(差一)",
|
||
integer_division: "整数除法或取余用错",
|
||
type_overflow: "数据类型不对或溢出(int 不够、浮点精度)",
|
||
uninitialized: "变量没初始化,或累加器没清零",
|
||
missing_case: "漏了特殊情况(0、负数、边界值)",
|
||
runtime_error: "运行时错误(下标越界、除以零)",
|
||
timeout: "超时(算法太慢或死循环)",
|
||
wrong_approach: "思路整体不对",
|
||
other: "其他,或者看不出来",
|
||
} as const
|
||
|
||
export type HintErrorTag = keyof typeof HINT_ERROR_TAGS
|
||
|
||
/**
|
||
* 诊断的出参。**只有枚举和数字,不允许任何自由文本** —— 诊断那一段能看到标准答案,
|
||
* 学生代码又是它的输入,出参里只要有一段文字就是一条把答案带出去的通道。
|
||
* 这样注入最多能左右一个枚举值和两个行号。多出来的字段被 zod 剥掉。
|
||
*/
|
||
export const hintDiagnosisSchema = z.object({
|
||
tag: z.enum(Object.keys(HINT_ERROR_TAGS) as [HintErrorTag, ...HintErrorTag[]]),
|
||
/** 问题所在的行号区间(从 1 起,含两端);说不准就是 null */
|
||
lines: z.tuple([z.number().int().min(1), z.number().int().min(1)]).nullable(),
|
||
confidence: z.enum(["high", "low"]),
|
||
})
|
||
|
||
export type HintDiagnosis = z.infer<typeof hintDiagnosisSchema>
|
||
|
||
/**
|
||
* 学生对一条 AI 提示的评价(POST /ai/hint/:id/feedback)。提示的 id 由 /ai/hint 流的
|
||
* `done` 事件带回来。可以改票,以最后一次为准。
|
||
*/
|
||
export const aiHintFeedbackRequestSchema = z.object({ helpful: z.boolean() })
|
||
|
||
/**
|
||
* 班级 AI 分析只传班号,数字在服务端按「班级详情」那一套现算 —— 原来是前端把弹框里的
|
||
* 统计值整包传上来,报告和抽屉里看到的数字可能对不上
|
||
*/
|
||
export const classAnalysisRequestSchema = z.object({
|
||
className: z.string().trim().min(1).max(32),
|
||
})
|
||
|
||
/** 班级 PK 的 AI 分析同样只传班号和时间段,数字和页面上的是同一份(services/ranking.ts 的 classPk) */
|
||
export const classPkAnalysisRequestSchema = z.object({
|
||
classNames: z.array(z.string().trim().min(1).max(32)).min(2).max(8),
|
||
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(),
|
||
model: z.string(),
|
||
data: z.record(z.string(), z.unknown()),
|
||
analysis: z.string(),
|
||
createTime: z.string(),
|
||
isPinned: z.boolean(),
|
||
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 AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
||
|
||
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
|
||
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
|
||
export type AiHintDone = z.infer<typeof aiHintDoneSchema>
|
||
export type AiHintFeedbackRequest = z.infer<typeof aiHintFeedbackRequestSchema>
|
||
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
|
||
export type ClassPkAnalysisRequest = z.infer<typeof classPkAnalysisRequestSchema>
|