Deploy / deploy (push) Canceled after 0s
- 前端 oj/api.ts、admin/api.ts、shared/api.ts 的 post / put 请求体一律 `satisfies XxxRequest`, 只在编译期检查,不加运行时校验(和「只在三处挂运行时闸门」的规矩一致) - 契约里的 XxxRequest 从 z.infer 改成 z.input:请求类型描述的是「客户端可以发什么」, 带默认值的字段可以不传。后端从不用这些类型(它用 parse 出来的 data),不受影响 - 补了四个漏导出的请求类型(比赛题目排序、题单题目排序、标签分类、检查用户名) - 接上之后对不上的几处:编辑用户的角色和题目权限经 toAdminType / toProblemPermission 收窄,导入用户的行改成四元组,练习题数据改成 Record,格式化的语言只收后端认的四种 (isFormatLanguage)。出题页和成就的请求体是「表单校验兜底、出站补默认值」, 用 Partial<…> 只核键名和值的类型 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
12 KiB
TypeScript
257 lines
12 KiB
TypeScript
import { z } from "zod"
|
||
|
||
import { classPkPeriodSchema } from "./ranking"
|
||
|
||
/**
|
||
* 智能分析看哪段时间。全按东八区日历切(同排名页):这周 = 本周一零点起,这个月 = 本月 1 日
|
||
* 零点起,这学期 = `termStart()`(9 月 1 日 / 2 月 1 日),全部 = 不设下限。
|
||
*/
|
||
export const learningPeriodSchema = z.enum(["week", "month", "term", "all"])
|
||
|
||
export const LEARNING_PERIOD_LABELS = {
|
||
week: "这周",
|
||
month: "这个月",
|
||
term: "这学期",
|
||
all: "全部",
|
||
} as const satisfies Record<z.infer<typeof learningPeriodSchema>, string>
|
||
|
||
/**
|
||
* 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂看板:代码 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(),
|
||
/** 班上第几个做对(只数这段时间里做对的同班同学);没做对、没有班级时为 null */
|
||
rank: z.number().int().nullable(),
|
||
/** 这段时间班上几人做对;没有班级时为 null */
|
||
classSolved: z.number().int().nullable(),
|
||
/** 没做对时最后一次代码提交的判题状态;只交过流程图时为 null */
|
||
lastResult: z.number().int().nullable(),
|
||
})
|
||
|
||
/**
|
||
* 一天一行。`lesson` 是班上这节课的题:老师在课堂看板布置过就用布置的,否则同班同一天
|
||
* ≥ 5 人做过的题(同「班里在做」的推断);一道题只归到一天。`own` 是这个学生这天自己练的、
|
||
* 不属于任何一节课的题,按他第一次交的那天归。
|
||
*/
|
||
export const learningDaySchema = z.object({
|
||
/** 东八区日历日 `YYYY-MM-DD` */
|
||
day: z.string(),
|
||
lesson: z.array(learningItemSchema),
|
||
own: z.array(learningItemSchema),
|
||
})
|
||
|
||
export const learningReportSchema = z.object({
|
||
username: z.string(),
|
||
className: z.string().nullable(),
|
||
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),
|
||
/**
|
||
* 和班上比。「班上」= 这段时间交过题的同班学生(同排名口径的学生角色),没有班级时为 null。
|
||
* `rank` 是按做对题数排第几(比他多的人数 + 1)。
|
||
*/
|
||
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() }),
|
||
),
|
||
/** 老师看学生时,同班按用户名排的前一个 / 后一个;学生看自己时为 null */
|
||
neighbors: z.object({ prev: z.string().nullable(), next: z.string().nullable() }).nullable(),
|
||
})
|
||
|
||
/**
|
||
* 请求只说「看谁、哪段时间」,学情数据由服务端自己算。
|
||
* 以前是 `details: z.unknown()` / `duration: z.unknown()` —— 前端算好的整包 POST 回去,
|
||
* 原样进 prompt 又原样写进 ai_analysis 表,等于让任何登录用户决定喂给模型什么。
|
||
*/
|
||
export const aiAnalysisRequestSchema = z.object({
|
||
period: learningPeriodSchema,
|
||
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,
|
||
})
|
||
|
||
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 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.input<typeof aiAnalysisRequestSchema>
|
||
export type AiHintRequest = z.input<typeof aiHintRequestSchema>
|
||
export type AiHintDone = z.infer<typeof aiHintDoneSchema>
|
||
export type AiHintFeedbackRequest = z.input<typeof aiHintFeedbackRequestSchema>
|
||
export type ClassAnalysisRequest = z.input<typeof classAnalysisRequestSchema>
|
||
export type ClassPkAnalysisRequest = z.input<typeof classPkAnalysisRequestSchema>
|