Files
OJ2/packages/contract/src/ai.ts
T
xuyueandClaude Opus 5.5 d83804b814 feat(班级PK): 班级 PK 只比两个班都布置过的同一批题,按做对的人占几成分输赢;登录了就能看,AI 分析由服务端现算
- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比;
  显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算)
- 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班,
  一样多挑班级对抗排得更前的
- 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算)
- /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:06:21 +08:00

289 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>