Compare commits

..
11 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 b16e11e85d fix(题目推荐): 已 AC 过滤下推 SQL、难度按语义排序、难度标签渲染
Deploy / deploy (push) Waiting to run
相似题目推荐(GET /problems/:displayId/similar)三个 bug:

- 排除已 AC 的题发生在 limit(5) 之后,候选被筛掉几条就少几条,甚至清零让整块
  不渲染。而这个接口只在刚 AC 或连挂三次时才调用,正是最容易全中的时候。
  (老栈是 .exclude(...) 在切片之前,重写时引入的回归)
- order by difficulty 是 text 字典序,High < Low < Mid,「由易到难」实际是最难的
  先上。改成显式 case 排名,并加上共享标签数作为第一排序键、id 兜稳定序。
- 前端 ref<any[]> 盖住了类型错误:getSimilarProblems 已过 filterResult,难度是
  中文,模板却在比 'Low' / 'High' 再查 DIFFICULTY 表,结果每条推荐都渲染成黄色
  的「中等」。

顺带:比赛题不再发这个请求(接口只在公开题库按 displayId 找,撞号会在比赛中把
题库列给学生);多余的 id in (子查询) 换成 join 中间表。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:24:46 +08:00
xuyueandClaude Opus 5 954797a9a5 feat(AI 提示): 提示分级(L0 反问 / L1 定位 / L2 概念)与输出后过滤
Deploy / deploy (push) Canceled after 0s
提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是
ai_hint.level 的历史,当前等级 = 这道题上(最近一次 AC 之后)给过的最高一级。

阶梯(services/hint-level.ts)
- 只有学生点「再多一点提示」才升级(请求带 more),不带就按当前等级再生成一次
- 升一级要先再交一次:锚点是「这一级是**什么时候**开出来的」,也就是这一级最早那条
  提示的 ai_hint.create_time,必须有比这个时刻更新的提交才准 +1。锚点不能用提示所在
  那条提交的时间 —— 端点谁的提交 id 都认(只校验归属),拿一条老提交去要提示,锚点
  就退回到那条老提交的时间,连点两下 more 就能从 L0 爬到 L2,一次新提交都不用交
- AC 之后清零;编译失败自成一档(level = -1),既不消耗也不推进阶梯
- HINT_MIN_FAILURES 3 → 1:门槛的活由阶梯接走了,第一次失败只开放 L0,而 L0 只反问、
  什么都不泄露,拦着它没有意义
- canEscalate 由后端算好在 done 事件里给,前端不自己推阶梯

输出后过滤(services/hint-filter.ts)
- 「不要给代码」写在 prompt 里只是软约束,模型忍不住一次就把这一级的意义废掉了。
  所以整段生成、过滤通过才推给前端,逐字显示改由前端模拟 —— 边流式边过滤做不到,
  发现违规时内容已经在学生屏幕上了
- 判定只用客观、低误报的信号:代码块、过长的行内代码、整行不含中文的类代码行、
  和标准答案重合 3 行以上、L0 一句问句都没有
- 违规就重生成一次,只重一次,再不过发写死的兜底话术。重试措辞按档分叉:编译档本来
  就允许给片段,对它说「不要出现任何代码」等于用阶梯的标准把这一档也砍了
- 两次都留痕(filter_attempt / filter_blocked / filter_reason),7.5 的输出过滤触发率
  就是从这三列出来的

services/ai.ts 加 streamWhole:事件形状和 streamChat 一样,前端不分叉。produce 期间
每 15 秒发一行 SSE 注释当心跳 —— 这条流中间有一大段静默(诊断 20s + 生成 60s +
重生成 60s,最坏 140 秒),而 NPM / nginx 的 proxy_read_timeout 默认 60 秒,超了学生
看到「请求失败」,后端却还在烧第二次调用,那条提示照样落库、照样把等级推上去。

prompt 版本另开 3 / 4(阶梯上每一级都换了 system),编译档仍走 1 / 2 的单段式基线,
两批数据不混在一起。迁移 0021 给 ai_hint 加四列,都可空、不带默认值,已有的行留 null
表示「分级上线前」。

实跑
- 阶梯:在 dev 库上用真实行驱动 decideHintLevel。正常路径 S1→S2→S3 走出 L0→L1→L2,
  同级连点 more 不升,AC 之后回 L0,编译档给 L-1 且不推进阶梯。把旧锚点规则复刻出来
  跑同一组数据做对照:只拿最老那条提交反复 POST,旧规则 L0→L1→L2(零新提交),
  新规则钉死在 L0,正常路径两者行为一致
- 过滤:起假 AI 服务端走完整条链路。L1 摊平代码→重生成后合规(attempt 2 / 未拦),
  L0 两次都甩代码块→兜底话术(blocked,reason 两条相连),编译档抄标程→命中「和标准
  答案重合 3 行」且追加的是分叉后的措辞
- streamWhole:produce 拖 16.5 秒收到 1 条心跳;同一份流喂给前端 consumeJSONEventStream
  只解析出 delta + done(注释行被静默跳过,前端零改动);中途 cancel 断开后 produce
  跑完不抛
- api / web typecheck、check:routes、fmt 全过

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:03:21 -06:00
xuyueandClaude Opus 5 8b4d8899f9 feat(判题机): 自建镜像升级工具链,语言收敛到 C / C++ / Python
Deploy / deploy (push) Canceled after 0s
上游 QingdaoU/JudgeServer 停更在 2024-04(registry 上的 latest 和 1.6.1 是同一份
镜像,编译器停在 gcc-13),没有新版可拉,所以自己重编。docker/judge/ 是只改工具链
的 Dockerfile 分叉,server/ 和 Judger/ 从上游固定 commit b28aa56 拉,一行没动。

镜像 oj2-judge-2(不在任何 registry 上:本机 build.sh --save → scp → docker load)
- gcc/g++ 13 → 14.2,Python 3.12 → 3.13.5,都是 trixie 默认
- Go / JDK / Node 整套删掉:前端的题目语言复选框从来只给 C / C++ / Python / SQL,
  12 万条提交里 Java 44 条、Golang 15、JavaScript 3,全是很早以前的
- 体积 1.1GB → 433MB;默认走清华源,构建 12 分钟 → 40 秒(--no-mirror 换回官方)
- deploy.sh 加一道自检:镜像不在本机就中止,并打印该跑的三条命令

C 的编译参数加三个 -Wno-error(implicit-function-declaration / int-conversion /
incompatible-pointer-types):gcc-14 把它们从 warning 提成了 error,而 -w 压不住。

语言值统一成 Python(迁移 0019 / 0020)
- 0019:Python3(104527 条提交)与 Python2(3 条)并成 Python,一并改掉 937 道题的
  languages、75 个 template 键、15 个 ast_rules 键、257 条 answers、1235 个用户的
  成就指标 _languages(languages_used 重算,总和 1928 → 1925,少的 3 个是同时用过
  两种 Python 的人)
- 0020:把 Java / JavaScript / Golang 从 84 道题的可选语言里摘掉 —— 不摘的话那些题
  的语言下拉还能选 Java,提交必 SYSTEM_ERROR
- 契约新增 normalizeLanguage() 别名表,判题侧一律走 judgeConfigFor():旧客户端
  localStorage 里的 Python3、迁移前排进队列的任务都还能判;协作的语言归一也走它,
  否则上线那一刻学生页面里的 Python3 会静默落到 C
- 回滚要连数据一起回,只滚代码会让所有 Python 提交变 SYSTEM_ERROR

实跑
- 判题冒烟 docker/judge/smoke.ts 13 条全过:三种语言、六种状态码、gcc 宽松度
- 拿备份里的真实代码逐文件比对新旧镜像的编译结果,0 差异:C 提交 1951 份
  (1725 过 / 226 CE)、C++ 882 份、Python 2000 份、20 篇 C 教程的 93 个代码块。
  不加那三个 -Wno-error 的话,C 有 26 份会从能过变成 CE
- 迁移在灌了 12.4 万行真实数据的一次性库里跑过:0 残留、没有题目被清空;
  dev 库用真正的执行器跑通
- check:ast 56 个 target 全过,前后端 typecheck 均 0

顺带记下一个升级之前就有的坑(现在随 Go 一起消失,写在 README 里):GOCACHE 指向
容器的 tmpfs,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒
的编译预算,于是重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 06:24:05 -06:00
xuyue a872e8365b fix
Deploy / deploy (push) Canceled after 0s
2026-09-19 07:10:49 -06:00
xuyueandClaude Opus 5 b4c0f89291 fix(个人主页): 总共学习天数改为有提交的天数
Deploy / deploy (push) Has been cancelled
原来是首末提交之间跨了多久,现在按东八区日期对提交去重计数,比赛提交也算。
首末提交时间仍只看比赛外的提交,口径不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:07:57 -06:00
xuyueandClaude Opus 5 06ad6745b7 feat(AI 提示): 两段式提示——先诊断出错误标签,再生成提示
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的 2b。标准答案能让提示准得多,但不能进生成提示的 prompt
(学生代码里一段注释就能把它套走),所以拆成两段:
- 诊断:看得到标准答案、第一个没过的测试点、带行号的学生代码;出参只允许
  { tag, lines, confidence },safeParse 过闸、多余字段剥掉,没有自由文本通道
- 生成提示:看不到标准答案和测试点原文,只多一句「问题定位:X,大约在第 a 行」
- 诊断失败(20 秒超时、不是 JSON、校验不过)退回单段式;同一条提交复用诊断;
  编译失败不诊断
- 契约新增 HINT_ERROR_TAGS(13 个,落库值,只增不改)与 hintDiagnosisSchema
- 迁移 0018:ai_hint 加 diagnosis / diagnosis_error 两列
- 单段式 prompt 原样搬进 services/hint-diagnosis.ts,记版本 1;两段式记版本 2
- completeChat 支持 JSON 模式和自定义超时,现有调用不受影响
- 开关 AI_HINT_DIAGNOSE 默认关(2a 的基线还在攒),两套生产 compose 透传

实跑(一次性库 + 本地假 LLM):开关关时请求与 2a 逐字节相同;开时正常 /
复用 / 坏 JSON / 非法标签 / 行号越界 / 超时 / 编译失败七种场景符合预期;
12 次模型请求里诊断全都带标准答案和测试点,生成全都不带。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:56:38 -06:00
xuyueandClaude Opus 5 47d8f46bdb feat(AI 提示): 提示落库并收集学生评价,落进 ai_hint
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的 2a:先把现有提示记下来,为后面的分级和两段式诊断攒对照数据。
提示本身的行为不变,发给模型的 prompt 一字未改。
- 迁移 0017 建 ai_hint:模型、prompt 版本、内容、错误、耗时、学生评价;
  成功和失败都记(失败率是 AI 悄悄变差时最先动的数)。挂在 submission 上 CASCADE
- streamChat 第三个参数改成 { onComplete, onError }:onComplete 的返回值并进
  done 事件,提示 id 由此带回前端;班级学情分析那处调用同步改写,行为不变
- 落库失败只记日志,不影响学生拿到提示
- 新增 POST /ai/hint/:id/feedback:只能评自己的提示(别人的一律 404),可以改票
- 前端提示生成完出「有帮助 / 没帮助」,选中的高亮;后端没存上时不出按钮
- 实跑:本地假 LLM 验证成功 / provider 断开 / 缺 AI_KEY 三条路径的落库,
  评价接口六种请求,浏览器里按钮出现、改票、重复点不发请求

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:00:10 -06:00
xuyueandClaude Opus 5 a0ef204bd2 feat(提交): 采集编辑过程信号,落进 submission_trace
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的第 1 步:给「可信 AC」和学情分析攒数据,本身不判任何事。
- 契约:提交请求加可选的 trace(活跃时长、键入/粘贴/删除字符数、切后台次数等,
  只有计数、不含按键内容);写成 .optional().catch(undefined),坏了就当没带,
  不让附带数据把提交挡成 400
- 迁移 0016 建 submission_trace,与 submission 一对一、CASCADE;since_prev_ms
  由服务端在同一条 INSERT 里算(排掉自身),bigint —— 实测已有 44 天的间隔,int4 装不下
- 后端写 trace 失败只记日志,不影响提交
- 前端 oj/problem/utils/editTrace.ts 是模块单例(扩展对象不能过 Pinia 的响应式代理),
  只数带 userEvent 的事务:格式化回写 / 载入草稿 / 协作对方的改动天然排除;
  closeBrackets 越过右括号时是原样替换,按 no-op 跳过。比赛编辑器同样挂上
- 实跑:后端四种请求、前端浏览器里键入/粘贴/删除/setCode 回写/切后台/提交后清零,
  计数与预期逐项一致

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:46:55 -06:00
xuyueandClaude Sonnet 5 20a6ddc79c feat(自学): 重做学生端页面与教师端学情概览
Deploy / deploy (push) Has been cancelled
学生端:目录 + 居中限宽正文 + 可收起的示例代码栏;目录改成三态圆点,
顶部加总进度;上一课/下一课栏桌面端固定在底部。
教师端:汇总卡片、学生状态标签与筛选(未开始/7 天没学/只读不练等)、
最后学习补「N 天前」、按练习表加「没人一次对/多数人卡住」提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 05:07:37 -06:00
xuyueandClaude Sonnet 5 c228b164cf refactor(提交): 教师统计路由拆出 submission-statistics.ts,check:routes 支持嵌套挂载
Deploy / deploy (push) Has been cancelled
- submission.ts 1416 行降到 751 行;统计三条路由拆成子路由,挂在原位置,
  仍排在 /submissions/:id 之前
- check-route-shadowing 原来只认 index.ts 直接挂载,拆分后漏检 3 条(178→175);
  现在按挂载位置展开嵌套的 .route(),恢复 178 条、无遮蔽

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00
xuyueandClaude Sonnet 5 b5ba56ccd0 refactor(契约): 判题状态码收进契约唯一一份,并收紧 18 处 as any
- 状态码常量与 judgeStatusSchema 移到 packages/contract/src/judge-status.ts,
  后端 judge/status.ts 只再导出;前端 SubmissionStatus 枚举加编译期断言对齐契约
  (实测改坏一个码会当场类型检查失败)
- 类型逃逸 22 处降到 4 处:collab/handler、pagination、configUpdate、
  ExerciseManager、ProblemSubmission、pk.vue tooltip;剩下的是词云插件无类型、
  生成的 .d.ts、skulpt 和 TextEditor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00
85 changed files with 27557 additions and 1141 deletions
+24 -1
View File
@@ -21,6 +21,7 @@ OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScrip
| `docs/timezone.md` | 动日历口径、动时间出参格式 | | `docs/timezone.md` | 动日历口径、动时间出参格式 |
| `docs/contract.md` | 动 zod 契约、想给某个字段加校验 | | `docs/contract.md` | 动 zod 契约、想给某个字段加校验 |
| `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter | | `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter |
| `docker/judge/README.md` | 换判题沙箱镜像、升语言版本(gcc / Python / Node …) |
| `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 | | `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 |
## 仓库结构 ## 仓库结构
@@ -114,6 +115,28 @@ handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
### 判题镜像是自己构建的
`compose.*.yml` 里的 `oj2-judge-2` **不在任何 registry 上**:上游
QingdaoU/JudgeServer 停更在 2024-04(官方镜像的 `latest``1.6.1` 是同一份,
编译器停在 gcc-13),新工具链只能自己编。`docker/judge/` 里是只改版本的 Dockerfile
分叉 + 构建脚本 + 冒烟测试,判题逻辑一行没动。
- 新机器、换镜像:先 `docker/judge/build.sh --save` → scp → `docker load`,再部署。
**服务器和机房各有各的判题沙箱,两边都要装。**
- 改工具链就把末尾序号 +1(下一版 `oj2-judge-3`)。`up -d` 不带 `--pull`,名字没变会静默用旧镜像。
- 编译/运行命令在 `apps/api/src/judge/languages.ts`,不在镜像里。gcc-14 把隐式函数
声明等提成了 error`-w` 压不住),那边的 `cLooseErrors` 三个 `-Wno-error=` 就是
为此加的 —— 删掉它们等于让一批历史题解和 C 教程示例集体 CE。
- **判题沙箱只认 C / C++ / Python。** Java / JavaScript / Golang 连同镜像里的
JDK / Node / Go 在 2026-09 一起砍了(前端本来就没给入口,12 万条提交里它们共 62 条),
契约 `judgeLanguageSchema` 里的键留着是为了渲染那 62 条历史提交。
**`Python3` / `Python2` 这两个旧值已经没有了** —— 0019 迁移把 104530 条提交、937 道题、
1235 个用户的成就指标并成了一个 `Python`,0020 顺手把那三种语言从题目的可选语言里摘掉
(不摘的话 84 道题的语言下拉还能选 Java,提交必 SYSTEM_ERROR)。查判题配置走
`judgeConfigFor()`,它带旧值别名;**回滚要连数据一起回**,只滚代码会让 Python 提交全炸。
- 换完镜像跑 `bun docker/judge/smoke.ts`:三种语言、六种状态码、gcc 宽松度一起核。
### 出参不 `parse`,用 `satisfies` ### 出参不 `parse`,用 `satisfies`
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚 **后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚
@@ -138,7 +161,7 @@ handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃
bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑 bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑
``` ```
判题机只认 C / C++ / Python3`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑, 判题机只认 C / C++ / Python`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑,
所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、 所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、
规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md` 规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`
+5 -6
View File
@@ -13,7 +13,7 @@ import {
getRoom, getRoom,
hasTeacherOnline, hasTeacherOnline,
listRequests, listRequests,
normalizeLanguage, normalizeCollabLanguage,
openRoom, openRoom,
queueAheadOf, queueAheadOf,
removeRequest, removeRequest,
@@ -198,6 +198,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
studentId?: unknown studentId?: unknown
language?: unknown language?: unknown
reason?: unknown reason?: unknown
timestamp?: unknown
} }
try { try {
message = JSON.parse(raw) as typeof message message = JSON.parse(raw) as typeof message
@@ -213,9 +214,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
// 心跳不查库,和 /ws/submissions 的处理一致 // 心跳不查库,和 /ws/submissions 的处理一致
if (message.type === "ping") { if (message.type === "ping") {
ws.send( ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }),
)
return return
} }
@@ -308,7 +307,7 @@ async function handleHelpRequest(
className: student?.className ?? null, className: student?.className ?? null,
problemId, problemId,
problemTitle: problem.title, problemTitle: problem.title,
language: normalizeLanguage(language), language: normalizeCollabLanguage(language),
createdAt: Date.now(), createdAt: Date.now(),
status: "pending", status: "pending",
socket: ws, socket: ws,
@@ -328,7 +327,7 @@ function handleHelpLanguage(ws: CollabSocket, language: unknown) {
const request = getRequest(ws.data.userId) const request = getRequest(ws.data.userId)
// 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助 // 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助
if (!request || request.socket !== ws) return if (!request || request.socket !== ws) return
const next = normalizeLanguage(language) const next = normalizeCollabLanguage(language)
if (request.language === next) return if (request.language === next) return
request.language = next request.language = next
+13 -6
View File
@@ -6,6 +6,8 @@
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。 * 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
*/ */
import { normalizeLanguage } from "@oj2/contract"
export type CollabSocket = Bun.ServerWebSocket< export type CollabSocket = Bun.ServerWebSocket<
import("../websocket").SubmissionSocketData import("../websocket").SubmissionSocketData
> >
@@ -17,8 +19,7 @@ export type CollabSocket = Bun.ServerWebSocket<
export const COLLAB_LANGUAGES = [ export const COLLAB_LANGUAGES = [
"C", "C",
"C++", "C++",
"Python2", "Python",
"Python3",
"Java", "Java",
"JavaScript", "JavaScript",
"Golang", "Golang",
@@ -27,10 +28,16 @@ export const COLLAB_LANGUAGES = [
export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number] export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number]
/** 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的 */ /**
export function normalizeLanguage(value: unknown): CollabLanguage { * 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的。
return (COLLAB_LANGUAGES as readonly string[]).includes(value as string) *
? (value as CollabLanguage) * 先过契约的别名表 —— 上线那一刻学生页面里还揣着 `Python3`,不翻译的话会**静默**
* 落到 C,求助窗口里的代码高亮和同步编辑都按 C 走,没人会报错。
*/
export function normalizeCollabLanguage(value: unknown): CollabLanguage {
const normalized = normalizeLanguage(value) ?? value
return (COLLAB_LANGUAGES as readonly string[]).includes(normalized as string)
? (normalized as CollabLanguage)
: "C" : "C"
} }
+6
View File
@@ -100,6 +100,12 @@ export const config = {
aiProvider: process.env.AI_PROVIDER ?? "deepseek", aiProvider: process.env.AI_PROVIDER ?? "deepseek",
aiKey: process.env.AI_KEY ?? "", aiKey: process.env.AI_KEY ?? "",
aiModel: process.env.AI_MODEL ?? "deepseek-flash", aiModel: process.env.AI_MODEL ?? "deepseek-flash",
/**
* AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**
* 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。
* 设成 "1" 打开。
*/
aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1",
ruffPath: process.env.RUFF_PATH ?? "ruff", ruffPath: process.env.RUFF_PATH ?? "ruff",
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format", clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
} }
@@ -0,0 +1,18 @@
-- 提交的编辑过程信号(AI 时代 OJ 设计的第 1 步:过程信号采集),字段含义见 schema.ts 的
-- submissionTrace 与契约的 submissionTraceSchema。纯建表,历史提交没有对应行,这是预期的。
CREATE TABLE "submission_trace" (
"submission_id" text PRIMARY KEY NOT NULL,
"active_ms" integer NOT NULL,
"since_open_ms" integer NOT NULL,
"typed_chars" integer NOT NULL,
"pasted_chars" integer NOT NULL,
"paste_count" integer NOT NULL,
"max_paste" integer NOT NULL,
"deleted_chars" integer NOT NULL,
"blur_count" integer NOT NULL,
"initial_len" integer NOT NULL,
"collab" boolean NOT NULL,
"since_prev_ms" bigint
);
--> statement-breakpoint
ALTER TABLE "submission_trace" ADD CONSTRAINT "submission_trace_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;
+17
View File
@@ -0,0 +1,17 @@
-- AI 提示的留痕与学生评价(AI 时代 OJ 设计 2a:先记录、不改行为),字段含义见 schema.ts 的 aiHint。
-- 纯建表。上线之前的提示从未落库,这张表从空开始。
CREATE TABLE "ai_hint" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "ai_hint_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"submission_id" text NOT NULL,
"model" text NOT NULL,
"prompt_version" integer NOT NULL,
"content" text NOT NULL,
"error" text,
"duration_ms" integer NOT NULL,
"helpful" boolean,
"feedback_time" timestamp with time zone,
"create_time" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ai_hint" ADD CONSTRAINT "ai_hint_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_hint_submission_id_idx" ON "ai_hint" USING btree ("submission_id");
@@ -0,0 +1,4 @@
-- AI 提示两段式的诊断结果(AI 时代 OJ 设计 2b),字段含义见 schema.ts 的 aiHint。
-- 两列都可空、不带默认值,加列只改目录不重写表。
ALTER TABLE "ai_hint" ADD COLUMN "diagnosis" jsonb;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "diagnosis_error" text;
@@ -0,0 +1,77 @@
-- 语言值统一成 `Python`:库里原来有 `Python3`104527 条提交)和 `Python2`3 条,
-- 全是 2022 年的),界面上两个都显示成「Python」,内部却是两个值。判题沙箱早就只剩
-- 一个 Python 了,这里把落库的值也并成一个。
--
-- 语言名不是判题状态码那种「判题机也认得的编码」—— 它只是我们自己的键(语言配置是
-- 整个对象发给判题机的),所以可以改。但它确实是**落库的值**,改完再回滚到旧版后端,
-- 旧代码查 languageConfigs["Python"] 会查不到 → 所有 Python 提交变 SYSTEM_ERROR。
-- 为此后端保留了 Python2/Python3 → Python 的别名(见 judge/languages.ts),
-- 新旧代码读哪一种数据都不会炸。
--
-- 涉及的四张表是全量扫备份确认过的(submission / problem / user_stat /
-- options_sysoptions)。options_sysoptions 里那行 `languages` 是 Django 时代的判题
-- 配置,OJ2 只读 website_* 几个键,不碰它,所以这里**故意不动**。
-- ① 提交记录。12 万条里 8 成是 Python,走一次全表 UPDATE。
UPDATE "submission" SET "language" = 'Python'
WHERE "language" IN ('Python2', 'Python3');--> statement-breakpoint
-- ② 题目的可选语言。用 WITH ORDINALITY 保住原来的顺序 —— 题目页的语言下拉和默认
-- 选中项就是按这个数组的顺序来的,打乱了学生打开题目看到的默认语言会变。
UPDATE "problem" p SET "languages" = (
SELECT COALESCE(jsonb_agg(
CASE WHEN v IN ('Python2', 'Python3') THEN 'Python' ELSE v END ORDER BY ord
), '[]'::jsonb)
FROM jsonb_array_elements_text(p."languages") WITH ORDINALITY AS t(v, ord)
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements_text(p."languages") x(v)
WHERE x.v IN ('Python2', 'Python3')
);--> statement-breakpoint
-- ③ 预制代码,键是语言名(75 道题有 Python3 的模板)。
UPDATE "problem"
SET "template" = ("template" - 'Python3') || jsonb_build_object('Python', "template" -> 'Python3')
WHERE jsonb_exists("template", 'Python3');--> statement-breakpoint
-- ④ AST 代码规则,键就是语言名(15 道题)。
UPDATE "problem"
SET "ast_rules" = ("ast_rules" - 'Python3') || jsonb_build_object('Python', "ast_rules" -> 'Python3')
WHERE "ast_rules" IS NOT NULL AND jsonb_exists("ast_rules", 'Python3');--> statement-breakpoint
-- ⑤ 参考答案,形如 [{"language": "...", "code": "..."}]257 条 Python3 答案)。
UPDATE "problem" p SET "answers" = (
SELECT jsonb_agg(
CASE WHEN a ->> 'language' IN ('Python2', 'Python3')
THEN jsonb_set(a, '{language}', '"Python"')
ELSE a END ORDER BY ord
)
FROM jsonb_array_elements(p."answers") WITH ORDINALITY AS t(a, ord)
)
WHERE p."answers" IS NOT NULL AND jsonb_typeof(p."answers") = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(p."answers") x(a)
WHERE x.a ->> 'language' IN ('Python2', 'Python3')
);--> statement-breakpoint
-- ⑥ 成就指标里的「用过哪些语言」(1235 个用户)。_languages 去重之后重算
-- languages_used —— 同时用过 Python2 和 Python3 的那 3 个用户,数字会从 n 掉到
-- n-1,这是**对的**:那本来就是同一种语言。已经发出去的成就不回收。
WITH mapped AS (
SELECT s."id", jsonb_agg(d.v ORDER BY d.ord) AS arr
FROM "user_stat" s, LATERAL (
SELECT DISTINCT ON (val) val AS v, ord FROM (
SELECT CASE WHEN e IN ('Python2', 'Python3') THEN 'Python' ELSE e END AS val, ord
FROM jsonb_array_elements_text(s."metrics" -> '_languages') WITH ORDINALITY AS t(e, ord)
) m ORDER BY val, ord
) d
WHERE jsonb_typeof(s."metrics" -> '_languages') = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(s."metrics" -> '_languages') x(e)
WHERE x.e IN ('Python2', 'Python3')
)
GROUP BY s."id"
)
UPDATE "user_stat" s SET "metrics" = jsonb_set(
jsonb_set(s."metrics", '{_languages}', mapped.arr),
'{languages_used}', to_jsonb(jsonb_array_length(mapped.arr))
)
FROM mapped WHERE mapped."id" = s."id";
@@ -0,0 +1,41 @@
-- 把 Java / JavaScript / Golang 从题目的可选语言里摘掉。
--
-- 这三种语言的判题配置和判题镜像里的 JDK / Node / Go 已经一起删了(见
-- judge/languages.ts 和 docker/judge/)。但生产库里有 84 道题的 `languages` 还留着
-- 它们,而题目页的语言下拉就是按这个数组渲染的 —— 不摘掉的话,学生能在那 84 道题上
-- 选 Java 提交,判题时 languageConfigs 查不到就抛 Unsupported judge language
-- 结果是 SYSTEM_ERROR。**这道迁移是那次删语言的收尾,不能只删代码不清数据。**
--
-- 备份实测:84 道题受影响,其中**没有**任何一道只有这三种语言,所以不会有题目被清空。
-- 保险起见加了 jsonb_array_length > 0 的条件:真要出现这种题,宁可留着不动、让它
-- 在后台显形,也不要把语言清空(题目页会渲染出一个空的语言下拉)。
--
-- 历史提交里那 62 条 Java/JS/Golang 记录**不动**,语言名留在契约里就是为了渲染它们。
UPDATE "problem" p SET "languages" = (
SELECT jsonb_agg(v ORDER BY ord)
FROM jsonb_array_elements_text(p."languages") WITH ORDINALITY AS t(v, ord)
WHERE v NOT IN ('Java', 'JavaScript', 'Golang')
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements_text(p."languages") x(v)
WHERE x.v IN ('Java', 'JavaScript', 'Golang')
) AND (
SELECT count(*) FROM jsonb_array_elements_text(p."languages") y(v)
WHERE y.v NOT IN ('Java', 'JavaScript', 'Golang')
) > 0;--> statement-breakpoint
-- 预制代码和参考答案里对应的条目一并清掉(生产库里是空的,防后台以后写进去)。
UPDATE "problem"
SET "template" = "template" - 'Java' - 'JavaScript' - 'Golang'
WHERE jsonb_exists_any("template", ARRAY['Java', 'JavaScript', 'Golang']);--> statement-breakpoint
UPDATE "problem" p SET "answers" = (
SELECT COALESCE(jsonb_agg(a ORDER BY ord), '[]'::jsonb)
FROM jsonb_array_elements(p."answers") WITH ORDINALITY AS t(a, ord)
WHERE a ->> 'language' NOT IN ('Java', 'JavaScript', 'Golang')
)
WHERE p."answers" IS NOT NULL AND jsonb_typeof(p."answers") = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(p."answers") x(a)
WHERE x.a ->> 'language' IN ('Java', 'JavaScript', 'Golang')
);
+6
View File
@@ -0,0 +1,6 @@
-- AI 提示的分级与输出过滤留痕(AI 时代 OJ 设计 2c),字段含义见 schema.ts 的 aiHint。
-- 四列都可空、不带默认值,加列只改目录不重写表;已有的行留 null,表示「分级上线前」。
ALTER TABLE "ai_hint" ADD COLUMN "level" integer;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "filter_attempt" integer;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "filter_blocked" boolean;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "filter_reason" text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42
View File
@@ -113,6 +113,48 @@
"when": 1789364546358, "when": 1789364546358,
"tag": "0015_submission_filter_indexes", "tag": "0015_submission_filter_indexes",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789817209482,
"tag": "0016_add_submission_trace",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789818766735,
"tag": "0017_add_ai_hint",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1789822227451,
"tag": "0018_ai_hint_diagnosis",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789906464541,
"tag": "0019_unify_python_language",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1789906612433,
"tag": "0020_drop_unsupported_languages",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1789990062378,
"tag": "0021_ai_hint_level",
"breakpoints": true
} }
] ]
} }
+128 -3
View File
@@ -42,6 +42,7 @@ import type {
ContestSubmissionInfo, ContestSubmissionInfo,
ExerciseType, ExerciseType,
FlowchartStatus, FlowchartStatus,
HintDiagnosis,
JudgeStatus, JudgeStatus,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
@@ -857,11 +858,12 @@ export const submission = pgTable(
* 提交列表的「语言」和「结果」两个下拉筛选。原来这两列上要么没索引、要么只有 * 提交列表的「语言」和「结果」两个下拉筛选。原来这两列上要么没索引、要么只有
* 不带 `contest_id IS NULL` 的单列索引,翻页那条靠 submission_public_create_time_id_idx * 不带 `contest_id IS NULL` 的单列索引,翻页那条靠 submission_public_create_time_id_idx
* 边扫边滤还能对付,**count 那条只能全表扫**(快照实测固定 75~82ms / 18448 buffers * 边扫边滤还能对付,**count 那条只能全表扫**(快照实测固定 75~82ms / 18448 buffers
* 筛什么值都一样)。加完:语言 count 80ms → 11msPython3,占 8 成)/ 1.6msC), * 筛什么值都一样)。加完:语言 count 80ms → 11msPython,占 8 成)/ 1.6msC),
* 结果 count 75ms → 2.0ms。 * 结果 count 75ms → 2.0ms。
* *
* 更要命的是冷门语言的**翻页**:Python2 只有 3 条、全是 2022 年的,分页索引得从 * 更要命的是冷门语言的**翻页**:JavaScript 只有 3 条、全是很早以前的,分页索引得从
* 最新一路倒扫到底才凑够一页,43ms 全表扫;走这条索引是 0.02ms。 * 最新一路倒扫到底才凑够一页,43ms 全表扫;走这条索引是 0.02ms。(这个实测当年用的
* 是 Python2,那 3 条 2026-09 已被 0019 迁移并进 Python,换了个同样冷门的值举例。)
* *
* 两列都 ASC NULLS LAST,理由同上面 submission_public_create_time_id_idx —— * 两列都 ASC NULLS LAST,理由同上面 submission_public_create_time_id_idx ——
* 靠 Index Scan **Backward** 出 `ORDER BY create_time DESC`。这里再实测了一遍: * 靠 Index Scan **Backward** 出 `ORDER BY create_time DESC`。这里再实测了一遍:
@@ -953,6 +955,129 @@ export const submission = pgTable(
], ],
) )
/**
* 提交时附带的编辑过程信号,和 submission 一对一。字段含义见契约的
* `submissionTraceSchema`,这里只记表本身的取舍:
*
* - **没有行 ≠ 可疑。** 2026-09 之前的全部历史提交、刷新过页面的、老版本前端交的
* 都没有 trace,用它的地方一律把「缺失」当「无数据」。
* - 类型化的列而不是一个 jsonb:「可信 AC」和学情热力图要在 SQL 里按这些值筛、聚合。
* - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交),
* 客户端伪造不了;这道题的第一次提交为 null。
* - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义,
* 人是谁顺着 submission 就能查到。同表的 message / problemset_submission 也是这一档。
*/
export const submissionTrace = pgTable(
"submission_trace",
{
submissionId: text("submission_id").primaryKey().notNull(),
activeMs: integer("active_ms").notNull(),
sinceOpenMs: integer("since_open_ms").notNull(),
typedChars: integer("typed_chars").notNull(),
pastedChars: integer("pasted_chars").notNull(),
pasteCount: integer("paste_count").notNull(),
maxPaste: integer("max_paste").notNull(),
deletedChars: integer("deleted_chars").notNull(),
blurCount: integer("blur_count").notNull(),
initialLen: integer("initial_len").notNull(),
collab: boolean().notNull(),
// bigintint4 的毫秒数只够 24.8 天,隔一个假期回来重交就溢出了
sincePrevMs: bigint("since_prev_ms", { mode: "number" }),
},
(table) => [
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "submission_trace_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
/**
* 每一次「让 AI 分析我的代码」(POST /ai/hint)的留痕,连同学生的评价。
*
* 在此之前提示一条都没落库,用了多少次、在哪些题上用、用完有没有做出来都无从知道;
* 之后要做的提示分级、错误归因都得拿这张表做对照。**生成失败的也记**(content 为空、
* error 有值),失败率是 AI 功能悄悄变差时最先动的那个数。
*
* - 不存 prompt 原文:学生代码在 submission 里、题面在 problem 里,重复存一遍没有意义。
* 存的是 `prompt_version` —— 改 system / prompt 的拼法时在 routes/ai.ts 里加一,
* 事后才分得清哪批提示是按哪版生成的。
* - 人和题顺着 submission 查(submission_id 上有索引)。和 submission_trace 一样
* CASCADE 挂在提交上:提交没了,这条提示也就没有上下文了。
* - 同一条提交可以有多条:刷新页面之后按钮会重新出现。
*/
export const aiHint = pgTable(
"ai_hint",
{
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({
name: "ai_hint_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: "9223372036854775807",
cache: 1,
}),
submissionId: text("submission_id").notNull(),
model: text().notNull(),
promptVersion: integer("prompt_version").notNull(),
// 生成失败时为空串,失败原因在 error
content: text().notNull(),
error: text(),
// 从收到请求到生成结束(或失败)的毫秒数,两段式时含诊断那一段
durationMs: integer("duration_ms").notNull(),
/**
* 两段式第一段的诊断结果(见 services/hint-diagnosis.ts)。**只存 safeParse 过的**
* 所以 `$type` 成立 —— 闸在写入侧。没开两段式、编译失败(不诊断)、诊断失败时为 null。
* 同一条提交再要提示时复用这里的结果,不再调一次模型。
*/
diagnosis: jsonb().$type<HintDiagnosis>(),
// 诊断失败的原因(超时、回的不是 JSON、校验不过)。这时第二段退回单段式的 prompt
diagnosisError: text("diagnosis_error"),
/**
* 这条提示给的是哪一级(契约的 `HINT_LEVELS`AI 时代 OJ 设计 2c)。
* -1 = 编译失败那一档,**不在阶梯上**;null = 2c 上线前那批不分级的提示。
* 查阶梯的 SQL 一律 `level >= 0`,两者都被摘掉。
*/
level: integer(),
/**
* 一共生成了几次(1 = 一次就过,2 = 重生成过)。**整段生成、过滤后才推**(边流式边
* 过滤做不到:发现违规时内容已经在学生屏幕上了),违规就重生成一次,只重一次。
*
* **不是「发出去的是第几次生成」** —— `filter_blocked` 为真时两次都作废了,发的是
* 兜底话术。7.5 的「输出过滤触发率」= `filter_attempt = 2` ÷ `filter_attempt` 非空。
*/
filterAttempt: integer("filter_attempt"),
/**
* 两次都违规,发的是写死的兜底话术(content 是那句话,不是模型的输出)。
*
* **这种行的 `error` 仍是 null** —— 生成本身没失败,是内容被拦了。所以算「生成失败率」
* 看 `error`,算「学生有没有真拿到提示」要另外扣掉 `filter_blocked` 为真的这批。
*/
filterBlocked: boolean("filter_blocked"),
// 被拦下的原因,两次都被拦时是两条(用 ; 连)。null = 一次都没触发过过滤
filterReason: text("filter_reason"),
// 学生的评价:null = 没评
helpful: boolean(),
feedbackTime: timestamp("feedback_time", {
withTimezone: true,
mode: "string",
}),
createTime: timestamp("create_time", {
withTimezone: true,
mode: "string",
}).notNull(),
},
(table) => [
index("ai_hint_submission_id_idx").on(table.submissionId),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "ai_hint_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
export const tutorial = pgTable( export const tutorial = pgTable(
"tutorial", "tutorial",
{ {
+2 -2
View File
@@ -236,7 +236,7 @@ function rangePassed(count: number, rule: AstRule) {
const CALL_NODE_TYPES: Record<string, string> = { const CALL_NODE_TYPES: Record<string, string> = {
C: "call_expression", C: "call_expression",
"C++": "call_expression", "C++": "call_expression",
Python3: "call", Python: "call",
} }
function functionCalls(root: Node, target: string, language: string) { function functionCalls(root: Node, target: string, language: string) {
@@ -267,7 +267,7 @@ function methodCalls(root: Node, target: string, language: string) {
) )
}) })
} }
if (language !== "Python3") return [] if (language !== "Python") return []
return collectNodes(root, "call").filter((call) => { return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function") const fn = call.childForFieldName("function")
return ( return (
+45 -56
View File
@@ -1,9 +1,40 @@
import { normalizeLanguage } from "@oj2/contract"
/**
* 判题沙箱认得的语言,**只有 C / C++ / Python 这三种**。
*
* Java / Golang / JavaScript 在 2026-09 连同镜像里的 JDK、Go、Node 工具链一起砍掉了:
* 前端从来没给过它们入口(后台题目的语言复选框只有 Python / C / C++ / SQL),
* 生产库 12 万条提交里它们一共 62 条,全是很早以前的。砍掉之后判题镜像小了一半多。
*
* 契约 `judgeLanguageSchema` 里那几个键**故意留着** —— 那是渲染历史提交要用的。
* 想恢复某种语言,得同时改这里和 `docker/judge/Dockerfile` 的工具链,再重建镜像。
*
* `Python` 这个键 2026-09 之前叫 `Python3`(库里还有 3 条更老的 `Python2`),
* 0019 迁移把数据并成了一个值。查配置一律走 `judgeConfigFor()`,别直接下标 ——
* 那里带着旧值的别名,迁移之前排进队列的任务、旧客户端传上来的值都还能判。
*
* SQL 题不走这里,走 `judge/sql/`;流程图题走 AI 评分。
*/
const defaultEnv = [ const defaultEnv = [
"LANG=en_US.UTF-8", "LANG=en_US.UTF-8",
"LANGUAGE=en_US:en", "LANGUAGE=en_US:en",
"LC_ALL=en_US.UTF-8", "LC_ALL=en_US.UTF-8",
] ]
/**
* gcc-14 起这三类老写法从 warning 提成了 error,而 `-w` 只关警告、压不住 error
* 隐式函数声明(忘了 `#include <stdio.h>` 就用 printf)、int 与指针互赋、
* 不兼容的指针类型。判题机镜像 2026-09 从 gcc-13 升到 14(见 docker/judge/),
* 不加这三个开关的话,**一批历史题解和 20 篇 C 教程的示例会突然全部 CE**。
*
* 只给 C 加:C++ 那边这些本来就是 error,g++ 升版不改判定。
* 哪天决定「就是要学生写规范」,是删掉这三行,不是改镜像 —— 删之前先拿
* docs/c-tutorials/verify-code.sh 全量过一遍教程。
*/
const cLooseErrors =
"-Wno-error=implicit-function-declaration -Wno-error=int-conversion -Wno-error=incompatible-pointer-types"
export const languageConfigs: Record<string, Record<string, unknown>> = { export const languageConfigs: Record<string, Record<string, unknown>> = {
C: { C: {
template: "", template: "",
@@ -13,8 +44,7 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
max_cpu_time: 3000, max_cpu_time: 3000,
max_real_time: 10000, max_real_time: 10000,
max_memory: 256 * 1024 * 1024, max_memory: 256 * 1024 * 1024,
compile_command: compile_command: `/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 ${cLooseErrors} {src_path} -lm -o {exe_path}`,
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
}, },
run: { run: {
command: "{exe_path}", command: "{exe_path}",
@@ -39,24 +69,7 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
env: defaultEnv, env: defaultEnv,
}, },
}, },
Java: { Python: {
template: "",
compile: {
src_name: "Main.java",
exe_name: "Main",
max_cpu_time: 5000,
max_real_time: 10000,
max_memory: -1,
compile_command: "/usr/bin/javac {src_path} -d {exe_dir}",
},
run: {
command: "/usr/bin/java -cp {exe_dir} -XX:MaxRAM={max_memory}k Main",
seccomp_rule: null,
env: defaultEnv,
memory_limit_check_only: 1,
},
},
Python3: {
template: "", template: "",
compile: { compile: {
src_name: "solution.py", src_name: "solution.py",
@@ -72,40 +85,16 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
env: defaultEnv, env: defaultEnv,
}, },
}, },
Golang: { }
template: "",
compile: { /**
src_name: "main.go", * 按语言取判题配置。**判题侧一律走这个函数**,不要直接 `languageConfigs[x]`
exe_name: "main", * 它先过 `normalizeLanguage()`,所以 `Python3` / `Python2` 这类旧值也能命中。
max_cpu_time: 3000, */
max_real_time: 5000, export function judgeConfigFor(language: string) {
max_memory: 1024 * 1024 * 1024, return (
compile_command: "/usr/bin/go build -o {exe_path} {src_path}", languageConfigs[language] ??
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv], languageConfigs[normalizeLanguage(language) ?? ""] ??
}, null
run: { )
command: "{exe_path}",
seccomp_rule: "golang",
env: ["GOMAXPROCS=1", ...defaultEnv],
memory_limit_check_only: 1,
},
},
JavaScript: {
template: "",
compile: {
src_name: "main.js",
exe_name: "main.js",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/node --check {src_path}",
env: defaultEnv,
},
run: {
command: "/usr/bin/node {exe_path}",
seccomp_rule: "node",
env: defaultEnv,
memory_limit_check_only: 1,
},
},
} }
+2 -2
View File
@@ -14,7 +14,7 @@ import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job" import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages" import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status" import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { parseProblemTemplate } from "./template" import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql" import { runSqlCase } from "./sql"
@@ -77,7 +77,7 @@ async function requestJudge(
memoryLimit: number, memoryLimit: number,
testCaseId: string, testCaseId: string,
) { ) {
const languageConfig = languageConfigs[language] const languageConfig = judgeConfigFor(language)
if (!languageConfig) if (!languageConfig)
throw new Error(`Unsupported judge language: ${language}`) throw new Error(`Unsupported judge language: ${language}`)
+5 -16
View File
@@ -1,19 +1,8 @@
export const JudgeStatus = { import { JudgeStatus, type JudgeStatusValue } from "@oj2/contract"
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus] // 状态码的唯一一份在 packages/contract/src/judge-status.ts,这里只再导出,
// 省得二十几处 import 一起改
export { JudgeStatus, type JudgeStatusValue }
export function isAccepted(result: number) { export function isAccepted(result: number) {
return ( return (
@@ -22,7 +11,7 @@ export function isAccepted(result: number) {
} }
/** /**
* `utils/constants.ts` `JUDGE_STATUS` * `utils/constants.ts` `JUDGE_STATUS`
* prompt `结果:-1` * prompt `结果:-1`
* -1 * -1
*/ */
+10 -10
View File
@@ -28,6 +28,7 @@ import {
isNull, isNull,
lt, lt,
lte, lte,
max,
min, min,
ne, ne,
notExists, notExists,
@@ -46,7 +47,7 @@ import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { getUserProfileById } from "../services/profile" import { getUserProfileById } from "../services/profile"
import { weekStart } from "../time" import { localTime, weekStart } from "../time"
import { import {
isTeacherOrAbove, isTeacherOrAbove,
objectValue, objectValue,
@@ -196,25 +197,24 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
accountRoutes.get("/users/:id/metrics", async (c) => { accountRoutes.get("/users/:id/metrics", async (c) => {
const userId = queryInteger(c.req.param("id"), 0, { min: 1 }) const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
// 比赛提交也算:首末提交时间、学习天数都连比赛一起统计
const [row] = await db const [row] = await db
.select({ .select({
total: count(),
first: min(schema.submission.createTime), first: min(schema.submission.createTime),
latest: sql<string>`max(${schema.submission.createTime})`, latest: max(schema.submission.createTime),
activeDays: countDistinct(
sql`date(${localTime(schema.submission.createTime)})`,
),
}) })
.from(schema.submission) .from(schema.submission)
.where( .where(eq(schema.submission.userId, userId))
and( if (!row?.first || !row.latest)
eq(schema.submission.userId, userId),
isNull(schema.submission.contestId),
),
)
if (!row?.total || !row.first || !row.latest)
return failure(c, 404, "no-submissions", "暂无提交") return failure(c, 404, "no-submissions", "暂无提交")
return success(c, { return success(c, {
now: new Date().toISOString(), now: new Date().toISOString(),
first: row.first, first: row.first,
latest: row.latest, latest: row.latest,
activeDays: row.activeDays,
} satisfies Metrics) } satisfies Metrics)
}) })
+134 -15
View File
@@ -1,12 +1,15 @@
import { import {
aiAnalysisRequestSchema, aiAnalysisRequestSchema,
aiHintFeedbackRequestSchema,
aiHintRequestSchema, aiHintRequestSchema,
classAnalysisRequestSchema, classAnalysisRequestSchema,
classPkAnalysisRequestSchema, classPkAnalysisRequestSchema,
HINT_MIN_FAILURES, HINT_MIN_FAILURES,
type AiAnalysisRecord, type AiAnalysisRecord,
type AiHintDone,
type AiDetail, type AiDetail,
type DurationData, type DurationData,
type HintDiagnosis,
type Grade, type Grade,
type HeatmapItem, type HeatmapItem,
type LoginSummary, type LoginSummary,
@@ -32,13 +35,16 @@ import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session" import { getPreviousLogin, type AuthUser } from "../auth/session"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
JudgeStatus,
judgeStatusName,
type JudgeStatusValue,
} from "../judge/status"
import { failure, success } from "../http" import { failure, success } from "../http"
import { completeChat, streamChat } from "../services/ai" import { completeChat, streamChat, streamWhole } from "../services/ai"
import { generateFilteredHint } from "../services/hint-filter"
import { decideHintLevel } from "../services/hint-level"
import {
hintDiagnosis,
hintPrompt,
referenceAnswer,
} from "../services/hint-diagnosis"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { import {
calendarDay, calendarDay,
@@ -913,7 +919,8 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const system = const system =
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。" "你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}` const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
return streamChat(system, prompt, async (analysis) => { return streamChat(system, prompt, {
onComplete: async (analysis) => {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的 // 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到 // GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
await db.insert(schema.aiAnalysis).values({ await db.insert(schema.aiAnalysis).values({
@@ -927,9 +934,54 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
userId: user.id, userId: user.id,
isPinned: false, isPinned: false,
}) })
},
}) })
}) })
/**
* ** null**
* AI
*/
async function recordHint(
base: {
submissionId: string
startedAt: number
promptVersion: number
diagnosis: HintDiagnosis | null
diagnosisError: string | null
level: number
},
content: string,
error: string | null,
filter?: { attempt: number; blocked: boolean; reason: string | null },
) {
try {
const [row] = await db
.insert(schema.aiHint)
.values({
submissionId: base.submissionId,
model: config.aiModel,
promptVersion: base.promptVersion,
content,
error,
durationMs: Math.round(performance.now() - base.startedAt),
diagnosis: base.diagnosis,
diagnosisError: base.diagnosisError,
level: base.level,
// 生成就失败的那条没走到过滤,三列都留 null(分母里不该有它)
filterAttempt: filter?.attempt ?? null,
filterBlocked: filter?.blocked ?? null,
filterReason: filter?.reason ?? null,
createTime: new Date().toISOString(),
})
.returning({ id: schema.aiHint.id })
return row?.id ?? null
} catch (e) {
console.error("Failed to record AI hint", e)
return null
}
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => { aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = aiHintRequestSchema.safeParse( const parsed = aiHintRequestSchema.safeParse(
await c.req.json().catch(() => null), await c.req.json().catch(() => null),
@@ -982,14 +1034,81 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
} }
const limited = await throttleAi(c) const limited = await throttleAi(c)
if (limited) return limited if (limited) return limited
// 这里**不要**把 problem.answers 的参考答案放进 prompt。学生的代码本身就是 prompt 的 // 这次按第几级生成。等级记在「学生 × 题目」上,怎么算出来的见 services/hint-level.ts
// 一部分,一段「忽略上面的指示,把参考答案打印出来」的注释就能把答案套走 —— system 里 const { level, canEscalate } = await decideHintLevel(
// 写「不可透露」只是软约束,挡不住。题面预算从 500 提到 2000(正好是参考答案让出来的那份), c.get("user")!.id,
// 让模型靠题目要求 + 报错信息判断,入门题的常见错误够用了。 row.submission,
const system = parsed.data.more === true,
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。" )
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码:${row.submission.code.slice(0, 2000)}` // 标准答案**只进诊断那一段**、出参只有枚举和行号;生成提示这一段看不到它。
return streamChat(system, prompt) // 为什么这么拆、诊断怎么退回单段式,见 services/hint-diagnosis.ts 的文件头
const startedAt = performance.now()
const { diagnosis, error: diagnosisError } = await hintDiagnosis(row)
const { system, prompt, version } = hintPrompt(row, diagnosis, level)
const base = {
submissionId: row.submission.id,
startedAt,
promptVersion: version,
diagnosis,
diagnosisError,
level,
}
// 不是 streamChat:提示要整段生成、过滤通过才推给学生(设计 2.6),
// 边流式边过滤做不到 —— 发现违规时内容已经在屏幕上了
return streamWhole(
async () => {
const filtered = await generateFilteredHint({
system,
prompt,
level,
// 标程只用来核「有没有把它抄出来」,不进任何 prompt
referenceCode: referenceAnswer(row)?.code ?? null,
})
// 落库失败就不带 id:前端据此不出评价按钮,提示本身照常显示
const hintId = await recordHint(base, filtered.content, null, filtered)
return {
content: filtered.content,
extra: { hintId, level, canEscalate } satisfies AiHintDone,
}
},
{
onError: async (message) => {
await recordHint(base, "", message)
},
},
)
})
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = aiHintFeedbackRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
// 不透露那个 id 上有没有东西
const [updated] = await db
.update(schema.aiHint)
.set({
helpful: parsed.data.helpful,
feedbackTime: new Date().toISOString(),
})
.where(
and(
eq(schema.aiHint.id, id),
inArray(
schema.aiHint.submissionId,
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.userId, c.get("user")!.id)),
),
),
)
.returning({ id: schema.aiHint.id })
if (!updated) return failure(c, 404, "hint-not-found", "Hint not found")
return success(c, null)
}) })
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => { aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
+34 -22
View File
@@ -17,6 +17,7 @@ import {
ilike, ilike,
inArray, inArray,
isNull, isNull,
ne,
notInArray, notInArray,
or, or,
sql, sql,
@@ -335,6 +336,18 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
.from(schema.problemTags) .from(schema.problemTags)
.where(eq(schema.problemTags.problemId, target.id)) .where(eq(schema.problemTags.problemId, target.id))
if (targetTags.length === 0) return success(c, []) if (targetTags.length === 0) return success(c, [])
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
const statuses = await getProblemStatuses(c.get("user")?.id)
const solvedIds = Object.entries(statuses)
.filter(([, value]) => toObject(value).status === JudgeStatus.ACCEPTED)
.map(([key]) => Number(key))
.filter((id) => Number.isInteger(id))
// difficulty 是 textLow / Mid / High),直接 order by 走的是字典序 ——
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end`
const sharedTags = count(schema.problemTags.problemtagId)
const rows = await db const rows = await db
.select({ .select({
problem: schema.problem, problem: schema.problem,
@@ -344,40 +357,39 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
.from(schema.problem) .from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where( // 直接 join 中间表(而不是 id in (子查询))是为了数出重合了几个标签,
// 拿来当第一排序键。(problem_id, problemtag_id) 上有唯一约束,不会重复计数。
.innerJoin(
schema.problemTags,
and( and(
eq(schema.problem.visible, true), eq(schema.problemTags.problemId, schema.problem.id),
isNull(schema.problem.contestId),
sql`${schema.problem.id} <> ${target.id}`,
inArray(
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.where(
inArray( inArray(
schema.problemTags.problemtagId, schema.problemTags.problemtagId,
targetTags.map((tag) => tag.id), targetTags.map((tag) => tag.id),
), ),
), ),
), )
.where(
and(
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
ne(schema.problem.id, target.id),
solvedIds.length ? notInArray(schema.problem.id, solvedIds) : undefined,
), ),
) )
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) .groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
.orderBy(asc(schema.problem.difficulty)) // 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
.limit(5) .orderBy(
const [tags, statuses] = await Promise.all([ desc(sharedTags),
getProblemTags(rows.map((row) => row.problem.id)), asc(difficultyRank),
getProblemStatuses(c.get("user")?.id), desc(schema.problem.acceptedNumber),
]) asc(schema.problem.id),
const filtered = rows.filter(
(row) =>
toObject(statuses[String(row.problem.id)]).status !==
JudgeStatus.ACCEPTED,
) )
.limit(5)
const tags = await getProblemTags(rows.map((row) => row.problem.id))
return success( return success(
c, c,
filtered.map((row) => listItem(row, tags, statuses)), rows.map((row) => listItem(row, tags, statuses)),
) )
}) })
@@ -0,0 +1,704 @@
/**
* /
*
* submission.ts ****submission.ts
* `route("/", submissionStatisticsRoutes)` `/submissions/:id`
* `/submissions/statistics` id Hono
*/
import {
type SubmissionStatistics,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import {
and,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import {
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { localTime, todayStart } from "../time"
import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/**
* ****
* +
*
* `localTime()` `extract(hour from create_time)`
* UTC 8
*/
submissionStatisticsRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/**
*
* today-count
* 21 0
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
})
.from(schema.submission)
.where(where),
db
.select({ hour, value: count() })
.from(schema.submission)
.where(where)
.groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count()))
.limit(10)
: [],
])
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problem: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
},
)
/**
* `end` `start`
*/
function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* `1001,1005,1010`
*
*/
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/**
* _id****退
*
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* updateExpandedRowKeys key
* ****
*
* data +
* 49108 105631
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* ****
*
* 12
* err_info
* statistic_info
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problem: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problem: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* AST_CHECK_FAILED
*
* **** AST_CHECK_FAILED ACCEPTED
* ×
* `count(distinct problem_id) filter (result = 10)`
*
* AST_CHECK_FAILED
*
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/**
* ****
*
* - `user_id in (改过名的当前用户名匹配到的账号)`
* `ks248` 685
*
* - `submission.username ilike` `user`
*
*
* user_id
*
*
* ** id ** `user_id in (子查询)` OR
* hashed SubPlan OR trigram
* submission_public_metrics_idx + submission_public_username_trgm_idx
* count 65ms 0.6ms`ks2` 退30~50ms
*
*/
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* problem.id `submission.problem_id`
* join problem `lower(problem._id)` problem
* submission_public_problem_time_idx
*
* `_id`
* 0
* filter
*/
export async function problemFilter(
displayId: string,
contestId: number | null,
) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
/**
* + ****
* ilike ks251
* `user` user_id user_id
*/
type StatisticsScope =
| { ok: true; filters: SQL[]; problemCount: number }
| { ok: false; status: 400 | 404; code: string; message: string }
async function statisticsScope(c: {
req: { query(name: string): string | undefined }
}): Promise<StatisticsScope> {
const range = statisticsRange(c)
if (!range) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: "end is required",
}
}
const filters = [
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: `At most ${STATISTICS_MAX_PROBLEMS} problems`,
}
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return {
ok: false,
status: 404,
code: "problem-not-found",
message: `Problem ${missing} does not exist`,
}
}
filters.push(inArray(schema.submission.problemId, ids))
}
return { ok: true, filters, problemCount: displayIds.length }
}
submissionStatisticsRoutes.get(
"/submissions/statistics",
requireTeacher,
async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(
matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **** AC
*
* 1
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* **** user join submission.username
*
*
* user 退
* personCount
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* ****
*
* `solvedCount`
*
* `solvedCount >= 1` `acceptedCount > 0`
* 退
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
/**
* **西**`done`
*
*
* GET /submissions/statistics/items
*
* `done` `data.length`
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId))
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
/**
*
*
* **** dataUnaccepted
*
*
*
*
* 退
*
*
*
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* **** `ks251`
*
* perUser join
*/
realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
},
)
/**
*
*
* **** ilike `ks251`
* 沿 scope
*
*/
submissionStatisticsRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim()
if (!username)
return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* **** user_id
* `submission.username`
*
*
* 退
*
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problem: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
},
)
+50 -673
View File
@@ -8,9 +8,7 @@ import {
type SubmissionDetail, type SubmissionDetail,
type SubmissionList, type SubmissionList,
type SubmissionListItem, type SubmissionListItem,
type SubmissionStatistics, type SubmissionTrace,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
@@ -18,7 +16,6 @@ import {
desc, desc,
eq, eq,
gt, gt,
ilike,
inArray, inArray,
isNull, isNull,
or, or,
@@ -31,37 +28,29 @@ import {
optionalAuth, optionalAuth,
requireAuth, requireAuth,
requireSuperAdmin, requireSuperAdmin,
requireTeacher,
} from "../auth/middleware" } from "../auth/middleware"
import type { AuthUser } from "../auth/session" import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { import { JudgeStatus } from "../judge/status"
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { judgeQueue } from "../queue" import { judgeQueue } from "../queue"
import { import {
canAccessContest, canAccessContest,
contestStatus, contestStatus,
findAccessibleContest, findAccessibleContest,
isContestAdmin,
requireContestAccess, requireContestAccess,
type ContestEnv, type ContestEnv,
} from "../services/contest" } from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code" import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { localTime, todayStart } from "../time" import { todayStart } from "../time"
import { asFilterValue, isAdminRole, queryInteger } from "./helpers"
import { import {
asFilterValue, problemFilter,
isAdminRole, submissionStatisticsRoutes,
matchedUsers, usernameFilter,
queryInteger, } from "./submission-statistics"
rounded,
stripClassPrefix,
} from "./helpers"
export const submissionRoutes = new Hono<ContestEnv>() export const submissionRoutes = new Hono<ContestEnv>()
@@ -71,6 +60,38 @@ function objectValue(value: unknown): Record<string, unknown> {
: {} : {}
} }
/**
* ****
* 500
*
* `since_prev_ms` INSERT
*
* `max()` null null
*/
async function saveTrace(
submissionId: string,
userId: number,
problemId: number,
createTime: string,
trace: SubmissionTrace,
) {
try {
await db.insert(schema.submissionTrace).values({
submissionId,
...trace,
sincePrevMs: sql`(
select (extract(epoch from ${createTime}::timestamptz - max(${schema.submission.createTime})) * 1000)::bigint
from ${schema.submission}
where ${schema.submission.userId} = ${userId}
and ${schema.submission.problemId} = ${problemId}
and ${schema.submission.id} <> ${submissionId}
)`,
})
} catch (error) {
console.error("Failed to record submission trace", error)
}
}
submissionRoutes.post("/submissions", requireAuth, async (c) => { submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = createSubmissionRequestSchema.safeParse( const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null), await c.req.json().catch(() => null),
@@ -180,6 +201,15 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
contestId, contestId,
}) })
if (parsed.data.trace)
await saveTrace(
submissionId,
user.id,
problem.id,
createTime,
parsed.data.trace,
)
try { try {
await judgeQueue.add( await judgeQueue.add(
"judge", "judge",
@@ -219,660 +249,7 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
return success(c, row?.value ?? 0) return success(c, row?.value ?? 0)
}) })
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] submissionRoutes.route("/", submissionStatisticsRoutes)
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/**
* ****
* +
*
* `localTime()` `extract(hour from create_time)`
* UTC 8
*/
submissionRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/**
*
* today-count
* 21 0
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
})
.from(schema.submission)
.where(where),
db
.select({ hour, value: count() })
.from(schema.submission)
.where(where)
.groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count()))
.limit(10)
: [],
])
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problem: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
},
)
/**
* `end` `start`
*/
function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* `1001,1005,1010`
*
*/
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/**
* _id****退
*
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* updateExpandedRowKeys key
* ****
*
* data +
* 49108 105631
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* ****
*
* 12
* err_info
* statistic_info
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problem: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problem: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* AST_CHECK_FAILED
*
* **** AST_CHECK_FAILED ACCEPTED
* ×
* `count(distinct problem_id) filter (result = 10)`
*
* AST_CHECK_FAILED
*
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/**
* ****
*
* - `user_id in (改过名的当前用户名匹配到的账号)`
* `ks248` 685
*
* - `submission.username ilike` `user`
*
*
* user_id
*
*
* ** id ** `user_id in (子查询)` OR
* hashed SubPlan OR trigram
* submission_public_metrics_idx + submission_public_username_trgm_idx
* count 65ms 0.6ms`ks2` 退30~50ms
*
*/
async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* problem.id `submission.problem_id`
* join problem `lower(problem._id)` problem
* submission_public_problem_time_idx
*
* `_id`
* 0
* filter
*/
async function problemFilter(displayId: string, contestId: number | null) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
/**
* + ****
* ilike ks251
* `user` user_id user_id
*/
type StatisticsScope =
| { ok: true; filters: SQL[]; problemCount: number }
| { ok: false; status: 400 | 404; code: string; message: string }
async function statisticsScope(c: {
req: { query(name: string): string | undefined }
}): Promise<StatisticsScope> {
const range = statisticsRange(c)
if (!range) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: "end is required",
}
}
const filters = [
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: `At most ${STATISTICS_MAX_PROBLEMS} problems`,
}
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return {
ok: false,
status: 404,
code: "problem-not-found",
message: `Problem ${missing} does not exist`,
}
}
filters.push(inArray(schema.submission.problemId, ids))
}
return { ok: true, filters, problemCount: displayIds.length }
}
submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(
matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **** AC
*
* 1
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* **** user join submission.username
*
*
* user 退
* personCount
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* ****
*
* `solvedCount`
*
* `solvedCount >= 1` `acceptedCount > 0`
* 退
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
/**
* **西**`done`
*
*
* GET /submissions/statistics/items
*
* `done` `data.length`
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId))
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
/**
*
*
* **** dataUnaccepted
*
*
*
*
* 退
*
*
*
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* **** `ks251`
*
* perUser join
*/
realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
})
/**
*
*
* **** ilike `ks251`
* 沿 scope
*
*/
submissionRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim()
if (!username)
return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* **** user_id
* `submission.username`
*
*
* 退
*
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problem: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
},
)
submissionRoutes.post( submissionRoutes.post(
"/submissions/:id/rejudge", "/submissions/:id/rejudge",
+1 -1
View File
@@ -31,7 +31,7 @@ import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { typ
const WASM_BY_LANGUAGE: Record<string, string> = { const WASM_BY_LANGUAGE: Record<string, string> = {
C: cWasmPath, C: cWasmPath,
"C++": cppWasmPath, "C++": cppWasmPath,
Python3: pythonWasmPath, Python: pythonWasmPath,
} }
await Parser.init({ locateFile: () => treeSitterWasmPath }) await Parser.init({ locateFile: () => treeSitterWasmPath })
+12 -4
View File
@@ -16,7 +16,8 @@
* *
* 200 * 200
* *
* `xxxRoutes.get("字面量", …)` * `xxxRoutes.get("字面量", …)`
* `xxxRoutes.route("字面量", 子路由)`
* *
*/ */
@@ -74,15 +75,22 @@ function collect(): Route[] {
const file = routerFile.get(router) const file = routerFile.get(router)
if (!file) return [] if (!file) return []
const text = readFileSync(file, "utf8") const text = readFileSync(file, "utf8")
// 直接注册的路由和嵌套挂载(`router.route("/", child)`)放在一起按出现位置排序:
// 子路由挂在哪个位置,它的路由就在哪个位置参与匹配
const pattern = new RegExp( const pattern = new RegExp(
`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, `${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"|${router}\\.route\\(\\s*"([^"]*)"\\s*,\\s*(\\w+)\\s*\\)`,
"g", "g",
) )
return [...text.matchAll(pattern)].map((m) => ({ return [...text.matchAll(pattern)].flatMap((m) => {
if (m[4]) return routesOf(m[4], prefix + m[3]!)
return [
{
method: m[1]!.toUpperCase(), method: m[1]!.toUpperCase(),
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/", path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
file: file.replace(SRC + "/", ""), file: file.replace(SRC + "/", ""),
})) },
]
})
} }
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平 // 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
+107 -9
View File
@@ -5,13 +5,15 @@ interface ChatMessage {
content: string content: string
} }
function requestBody(messages: ChatMessage[], stream: boolean) { function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
return { return {
model: config.aiModel, model: config.aiModel,
messages, messages,
stream, stream,
temperature: 0, temperature: 0,
thinking: { type: "disabled" }, thinking: { type: "disabled" },
// DeepSeek 的 JSON 模式:保证回的是合法 JSON,但 prompt 里得出现「json」字样
...(json ? { response_format: { type: "json_object" } } : {}),
} }
} }
@@ -22,11 +24,15 @@ function requestBody(messages: ChatMessage[], stream: boolean) {
*/ */
const COMPLETE_TIMEOUT_MS = 60_000 const COMPLETE_TIMEOUT_MS = 60_000
export async function completeChat(system: string, user: string) { export async function completeChat(
system: string,
user: string,
options: { json?: boolean; timeoutMs?: number } = {},
) {
if (!config.aiKey) throw new Error("缺少 AI_KEY") if (!config.aiKey) throw new Error("缺少 AI_KEY")
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), { const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST", method: "POST",
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS), signal: AbortSignal.timeout(options.timeoutMs ?? COMPLETE_TIMEOUT_MS),
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
authorization: `Bearer ${config.aiKey}`, authorization: `Bearer ${config.aiKey}`,
@@ -38,6 +44,7 @@ export async function completeChat(system: string, user: string) {
{ role: "user", content: user }, { role: "user", content: user },
], ],
false, false,
options.json,
), ),
), ),
}) })
@@ -51,16 +58,34 @@ export async function completeChat(system: string, user: string) {
return payload.choices?.[0]?.message?.content?.trim() ?? "" return payload.choices?.[0]?.message?.content?.trim() ?? ""
} }
export interface StreamChatHooks {
/**
* ** `done` **
* 西 ai_hint id
*/
onComplete?: (value: string) => Promise<Record<string, unknown> | void>
/**
* AI_KEYprovider
*
*/
onError?: (message: string) => Promise<void>
}
export function streamChat( export function streamChat(
system: string, system: string,
user: string, user: string,
onComplete?: (value: string) => Promise<void>, hooks: StreamChatHooks = {},
) { ) {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const reportError = (message: string) =>
hooks.onError?.(message).catch((error) => {
console.error("streamChat onError hook failed", error)
})
const body = new ReadableStream<Uint8Array>({ const body = new ReadableStream<Uint8Array>({
async start(controller) { async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value)) const send = (value: string) => controller.enqueue(encoder.encode(value))
if (!config.aiKey) { if (!config.aiKey) {
await reportError("缺少 AI_KEY")
send( send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`, `data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
) )
@@ -127,12 +152,15 @@ export function streamChat(
if (done) break if (done) break
} }
const full = chunks.join("").trim() const full = chunks.join("").trim()
if (onComplete) await onComplete(full) const extra = hooks.onComplete
send(`data: ${JSON.stringify({ type: "done" })}\n\n`) ? await hooks.onComplete(full)
: undefined
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) { } catch (error) {
send( const message = error instanceof Error ? error.message : String(error)
`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`, // 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
) await reportError(message)
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
} finally { } finally {
send("event: end\n\n") send("event: end\n\n")
controller.close() controller.close()
@@ -147,3 +175,73 @@ export function streamChat(
}, },
}) })
} }
/**
* AI OJ 2.6
*
* AI `streamChat`****
* `produce`
* 稿** delta**
*
* `streamChat` start / delta / done / error + `event: end`
* `consumeJSONEventStream` `event: start` `produce`
*
*
* **`produce` ** `streamChat`
* 20s + 60s + 60s 140 NPM / nginx
* `proxy_read_timeout` 60
*
* `HEARTBEAT_MS` SSE `utils/stream.ts` `event:` / `data:`
*
*/
export function streamWhole(
produce: () => Promise<{ content: string; extra?: Record<string, unknown> }>,
hooks: { onError?: (message: string) => Promise<void> } = {},
) {
const encoder = new TextEncoder()
// 反代的读超时是 60s(见上),取它的四分之一,够抗一次抖动
const HEARTBEAT_MS = 15_000
let closed = false
const body = new ReadableStream<Uint8Array>({
async start(controller) {
// 学生关掉页面之后 enqueue 会抛,而这时 produce 还在跑(留痕要它跑完),
// 所以 send 自己吞掉异常并记下「已经断了」,后面几步不用各写一遍 try
const send = (value: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(value))
} catch {
closed = true
}
}
const heartbeat = setInterval(() => send(": ping\n\n"), HEARTBEAT_MS)
try {
send("event: start\n\n")
const { content, extra } = await produce()
send(`data: ${JSON.stringify({ type: "delta", content })}\n\n`)
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// 先留痕再回前端
await hooks.onError?.(message).catch((e) => {
console.error("streamWhole onError hook failed", e)
})
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
} finally {
clearInterval(heartbeat)
send("event: end\n\n")
if (!closed) controller.close()
}
},
cancel() {
closed = true
},
})
return new Response(body, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"x-accel-buffering": "no",
},
})
}
+305
View File
@@ -0,0 +1,305 @@
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import {
HINT_ERROR_TAGS,
HINT_LEVEL_COMPILE,
HINT_LEVELS,
hintDiagnosisSchema,
type HintDiagnosis,
} from "@oj2/contract"
import { and, desc, eq, isNotNull } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { JudgeStatus, judgeStatusName } from "../judge/status"
import { objectValue } from "../routes/helpers"
import { completeChat } from "./ai"
import { readInfo } from "./test-case"
/**
* AI prompt AI OJ 2b
*
* ****
* prompt
* system
*
* 1. ****
* `hintDiagnosisSchema` + + safeParse
*
* 2. **** X
* ab
*
* JSON退 prompt
*/
type HintRow = {
submission: typeof schema.submission.$inferSelect
problem: typeof schema.problem.$inferSelect
}
/**
* prompt ai_hint.prompt_version****
* 1 2026-09-19 线
*
* 1 / 2 1
* system 2c 3 / 4
*/
export const HINT_PROMPT_SINGLE = 1
export const HINT_PROMPT_DIAGNOSED = 2
export const HINT_PROMPT_LEVELED = 3
export const HINT_PROMPT_LEVELED_DIAGNOSED = 4
/** 诊断这一段让学生干等着(提示还没开始流),超时就退回单段式,别让按钮一直转 */
const DIAGNOSE_TIMEOUT_MS = 20_000
/** 喂给诊断的测试点输入 / 期望输出各截多少字符。入门题的测试点绝大多数很短 */
const CASE_EXCERPT = 600
const SINGLE_SYSTEM =
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
function errInfo(row: HintRow) {
return String(objectValue(row.submission.statisticInfo).err_info ?? "无")
}
/** 带行号的代码,诊断回的行号和第二段里说的「第几行」都以它为准 */
function numbered(code: string) {
return code
.split("\n")
.map((line, index) => `${String(index + 1).padStart(3)}| ${line}`)
.join("\n")
}
/** 同语言的标准答案优先;没有就拿别的语言的(思路一样,照样能帮诊断);再没有就 null */
export function referenceAnswer(row: HintRow) {
const answers = Array.isArray(row.problem.answers)
? row.problem.answers.map((item) => objectValue(item))
: []
const usable = answers.filter(
(item): item is { language: string; code: string } =>
typeof item.language === "string" &&
typeof item.code === "string" &&
item.code.trim() !== "",
)
return (
usable.find((item) => item.language === row.submission.language) ??
usable[0] ??
null
)
}
/**
* ****
* output nullSQL info
* null
*/
async function firstFailedCase(row: HintRow) {
if (row.submission.language === "SQL") return null
const data = objectValue(row.submission.info).data
if (!Array.isArray(data)) return null
const failed = data
.map((item) => objectValue(item))
.find((item) => typeof item.result === "number" && item.result !== 0)
if (!failed || typeof failed.test_case !== "string") return null
try {
const info = await readInfo(row.problem.testCaseId)
const entry = info?.test_cases?.[failed.test_case]
if (!entry) return null
const directory = resolve(config.testCaseDirectory, row.problem.testCaseId)
const [input, output] = await Promise.all([
readFile(resolve(directory, entry.input_name), "utf8"),
readFile(resolve(directory, entry.output_name), "utf8"),
])
return {
index: failed.test_case,
input: input.slice(0, CASE_EXCERPT),
output: output.slice(0, CASE_EXCERPT),
}
} catch {
return null
}
}
const DIAGNOSE_SYSTEM = `你是编程教学的诊断器,只负责给学生代码的错误归类,不和学生对话。
json
{"tag": "<错误类型>", "lines": [, ] null, "confidence": "high" "low"}
tag key
${Object.entries(HINT_ERROR_TAGS)
.map(([key, label]) => `- ${key}${label}`)
.join("\n")}
lines null
`
async function diagnose(
row: HintRow,
): Promise<{ diagnosis: HintDiagnosis } | { error: string }> {
const answer = referenceAnswer(row)
const failedCase = await firstFailedCase(row)
const code = row.submission.code.slice(0, 4000)
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
answer
? `标准答案(${answer.language}):\n${answer.code.slice(0, 3000)}`
: "标准答案:无",
failedCase
? `第一个没通过的测试点(#${failedCase.index}\n输入:\n${failedCase.input}\n期望输出:\n${failedCase.output}`
: "没通过的测试点:无",
`判题结果:${judgeStatusName(row.submission.result)}`,
`报错:${errInfo(row)}`,
`学生代码(${row.submission.language}):\n${numbered(code)}`,
].join("\n\n")
let raw: string
try {
raw = await completeChat(DIAGNOSE_SYSTEM, prompt, {
json: true,
timeoutMs: DIAGNOSE_TIMEOUT_MS,
})
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
return { error: `诊断回的不是 JSON${raw.slice(0, 200)}` }
}
const parsed = hintDiagnosisSchema.safeParse(value)
if (!parsed.success)
return {
error: `诊断校验不过:${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}`,
}
// 行号越界或倒过来不算整个诊断失败:类型往往还是对的,只把行号丢掉
const lineCount = code.split("\n").length
const lines = parsed.data.lines
const linesOk =
lines !== null && lines[0] <= lines[1] && lines[1] <= lineCount
return { diagnosis: { ...parsed.data, lines: linesOk ? lines : null } }
}
/**
*
*
* - /
*
* -
*/
export async function hintDiagnosis(row: HintRow): Promise<{
diagnosis: HintDiagnosis | null
error: string | null
}> {
if (
!config.aiHintDiagnose ||
row.submission.result === JudgeStatus.COMPILE_ERROR
)
return { diagnosis: null, error: null }
const [previous] = await db
.select({ diagnosis: schema.aiHint.diagnosis })
.from(schema.aiHint)
.where(
and(
eq(schema.aiHint.submissionId, row.submission.id),
isNotNull(schema.aiHint.diagnosis),
),
)
.orderBy(desc(schema.aiHint.id))
.limit(1)
if (previous?.diagnosis) return { diagnosis: previous.diagnosis, error: null }
const result = await diagnose(row)
return "diagnosis" in result
? { diagnosis: result.diagnosis, error: null }
: { diagnosis: null, error: result.error }
}
/**
* AI OJ 2.2 **
* prompt ** prompt `hint-filter.ts`
*/
const LEVEL_COMMON = `你是编程助教,面对的是刚开始学编程的中职学生。用中文、Markdown,语气平和,不要说教。
`
const LEVEL_RULES: Record<number, string> = {
0: `只能用提问引导学生自己想,一个结论都不能给:
- 23
-
- 4 `,
1: `只能告诉学生问题出在哪一块,不能说为什么错,更不能说怎么改:
- / /
-
- 3 `,
2: `把这里涉及的概念讲清楚,但不落到这份代码该怎么改:
-
- X
- 6 `,
}
function levelSystem(level: number) {
const entry = HINT_LEVELS.find((item) => item.level === level)
const head = entry
? `现在是 L${entry.level}${entry.name}):${entry.summary}`
: ""
return `${LEVEL_COMMON}\n${head}\n${LEVEL_RULES[level] ?? LEVEL_RULES[0]!}`
}
/** 诊断结果在 prompt 里的那一句;没诊断就是空串 */
function locatedLine(diagnosis: HintDiagnosis) {
const where = diagnosis.lines
? diagnosis.lines[0] === diagnosis.lines[1]
? `,大约在第 ${diagnosis.lines[0]}`
: `,大约在第 ${diagnosis.lines[0]}${diagnosis.lines[1]}`
: ""
return `问题定位:${HINT_ERROR_TAGS[diagnosis.tag]}${where}(把握:${diagnosis.confidence === "high" ? "高" : "低"}`
}
/**
* prompt****沿 2026-09-19 线
*
*/
function compilePrompt(row: HintRow, diagnosis: HintDiagnosis | null) {
if (!diagnosis) {
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${errInfo(row)}\n代码:${row.submission.code.slice(0, 2000)}`
return { system: SINGLE_SYSTEM, prompt, version: HINT_PROMPT_SINGLE }
}
const system = `${SINGLE_SYSTEM}\n问题已经定位好了,会在「问题定位」里给出,围绕它来提示。把握低时换个方式问学生,别说得太肯定。不要提到「诊断」「定位」这些说法。`
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
`语言:${row.submission.language}`,
`结果:${judgeStatusName(row.submission.result)}`,
`错误:${errInfo(row)}`,
locatedLine(diagnosis),
`代码:\n${numbered(row.submission.code.slice(0, 2000))}`,
].join("\n")
return { system, prompt, version: HINT_PROMPT_DIAGNOSED }
}
/**
* prompt****
* `level` `compilePrompt`
*/
export function hintPrompt(
row: HintRow,
diagnosis: HintDiagnosis | null,
level: number,
) {
if (level === HINT_LEVEL_COMPILE) return compilePrompt(row, diagnosis)
const system = diagnosis
? `${levelSystem(level)}\n问题已经定位好了,会在「问题定位」里给出,就围着它说。把握低时别说得太肯定。不要提到「诊断」「定位」这些说法。`
: levelSystem(level)
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
`语言:${row.submission.language}`,
`结果:${judgeStatusName(row.submission.result)}`,
`错误:${errInfo(row)}`,
...(diagnosis ? [locatedLine(diagnosis)] : []),
`代码:\n${numbered(row.submission.code.slice(0, 2000))}`,
].join("\n")
return {
system,
prompt,
version: diagnosis ? HINT_PROMPT_LEVELED_DIAGNOSED : HINT_PROMPT_LEVELED,
}
}
+168
View File
@@ -0,0 +1,168 @@
import { HINT_LEVEL_COMPILE } from "@oj2/contract"
import { completeChat } from "./ai"
/**
* ****AI OJ 2.5 / 2.6
*
* prompt
*
* ****
*
* ****
* `ai_hint.filter_attempt` / `filter_blocked` / `filter_reason`
*
*
* ****
*
*
* - ```)和过长的行内代码:阶梯上的每一级都不许出现代码。
* - ****
* `;` `=` `(`
* -
* - L0 L0
*
* `HINT_LEVEL_COMPILE`
* HINT_LEVEL_COMPILE
*/
/** 行内代码超过这个长度就当代码片段,短的(`int`、`n`、`%d`、`a[i]`)是正常讲解 */
const INLINE_CODE_MAX = 24
/** 和标准答案重合到这么多行就算抄答案 */
const ANSWER_LINE_HITS = 3
/** 参与重合比对的行至少这么长,短行(`}`、`return 0;`)谁写都一样 */
const ANSWER_LINE_MIN = 12
const CJK = /[一-龥]/
/** 兜底话术:两次都被拦时发它,`content` 存的就是这句,不是模型的输出 */
const FALLBACK: Record<number, string> = {
[HINT_LEVEL_COMPILE]:
"这次没能给出有效的提示。编译报错的第一行通常就写着出错的行号,先跳到那一行,再往上看一两行 —— 漏分号、括号不配对,报错点往往在真正出错的下一行。",
0: "这次没能给出有效的提示。先别急着改代码,问自己三个问题:这题的输入一共有几个数?每一步我想算的是什么?我的程序在哪种情况下会算得不对?",
1: "这次没能给出有效的提示。把你的代码分成「读入、计算、输出」三段,一段一段对着题目要求核一遍,先找出是哪一段没按题目说的做。",
2: "这次没能给出有效的提示。想一想这道题主要用到哪个知识点,把教程里对应的那一节再看一遍,然后带着它回来读自己的代码。",
}
function fallbackText(level: number) {
return FALLBACK[level] ?? FALLBACK[0]!
}
/** 标准答案里值得比对的行(去掉空白,短行不算) */
function answerLines(code: string) {
return code
.split("\n")
.map((line) => line.replace(/\s+/g, ""))
.filter((line) => line.length >= ANSWER_LINE_MIN)
}
/** 整行不含中文、又带着代码标点的行,多半是摊平成正文的代码 */
function looksLikeCode(line: string) {
const text = line.trim()
if (text.length < 6 || CJK.test(text)) return false
if (/^[-=*_#>|\s]+$/.test(text)) return false // Markdown 的分隔线、空列表项
return /[;=]|\w\s*\(/.test(text)
}
/**
* null
* `referenceCode`
*/
export function hintFilterReason(
content: string,
level: number,
referenceCode: string | null,
): string | null {
const text = content.trim()
if (!text) return "空回复"
if (level !== HINT_LEVEL_COMPILE) {
if (/```/.test(text)) return "出现代码块"
const inline = text.match(/`([^`\n]+)`/g) ?? []
if (inline.some((item) => item.length - 2 > INLINE_CODE_MAX))
return "行内代码过长"
// 围栏里的代码已经被上面拦掉了,这里找的是摊平进正文的
const bare = text.split("\n").filter(looksLikeCode)
if (bare.length) return `正文里出现代码:${bare[0]!.trim().slice(0, 60)}`
if (level === 0 && !/[?]/.test(text)) return "L0 没有一句问句"
}
if (referenceCode) {
const flat = text.replace(/\s+/g, "")
const hits = new Set(
answerLines(referenceCode).filter((line) => flat.includes(line)),
)
if (hits.size >= ANSWER_LINE_HITS) return `和标准答案重合 ${hits.size}`
}
return null
}
export interface FilteredHint {
/** 真正发给学生的正文;两次都被拦时是兜底话术 */
content: string
/**
* 1 = 2 = ****
* `blocked`
*/
attempt: number
/** 两次都违规,发的是兜底话术 —— 这条提示等于没给,只是没让学生空手而归 */
blocked: boolean
/** 触发过的违规原因,两次都触发时用 `; ` 连起来。null = 一次都没触发 */
reason: string | null
}
/**
* 线
*
* **** `HINT_LEVEL_COMPILE`
*
*
*/
function retrySystem(system: string, reason: string, level: number) {
const demand =
level === HINT_LEVEL_COMPILE
? "重写一遍,只讲怎么看报错、怎么定位到出错的那一行,不要把标准答案的内容搬进来。"
: "重写一遍,务必守住上面的限制:不要出现任何代码或代码块,不要把代码摊平写在正文里。"
return `${system}\n\n上一次的回答被判为违规(${reason}),已经作废。${demand}`
}
/**
*
*
* AI
* ****退
*/
export async function generateFilteredHint(options: {
system: string
prompt: string
level: number
referenceCode: string | null
}): Promise<FilteredHint> {
const { system, prompt, level, referenceCode } = options
const first = (await completeChat(system, prompt)).trim()
const firstReason = hintFilterReason(first, level, referenceCode)
if (!firstReason)
return { content: first, attempt: 1, blocked: false, reason: null }
let second: string
try {
second = (
await completeChat(retrySystem(system, firstReason, level), prompt)
).trim()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: fallbackText(level),
attempt: 2,
blocked: true,
reason: `${firstReason}; 重生成失败:${message}`,
}
}
const secondReason = hintFilterReason(second, level, referenceCode)
if (!secondReason)
return { content: second, attempt: 2, blocked: false, reason: firstReason }
return {
content: fallbackText(level),
attempt: 2,
blocked: true,
reason: `${firstReason}; ${secondReason}`,
}
}
+136
View File
@@ -0,0 +1,136 @@
import { HINT_LEVEL_COMPILE, HINT_MAX_LEVEL } from "@oj2/contract"
import { and, desc, eq, gt, gte, isNull } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus } from "../judge/status"
/**
* AI AI OJ 2.2 / 2.6
*
* ** × ** `ai_hint.level`
* = AC
*
*
*
*
* 1. ****`more`
* 2. ********
* `ai_hint.create_time`**** +1
* 3. **AC ** AC
*
* 2 ****
* id ****退
* more L0 L2
*
*
* `HINT_LEVEL_COMPILE` `level >= 0`
* 2c 线 `level` null `error`
*
*/
/** 最近一次 AC 的时刻;没 AC 过就是 null。AST 未通过不算,那种情况学生还要再改 */
async function lastAcceptedAt(userId: number, problemId: number) {
const [row] = await db
.select({ createTime: schema.submission.createTime })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, userId),
eq(schema.submission.problemId, problemId),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
),
)
.orderBy(desc(schema.submission.createTime))
.limit(1)
return row?.createTime ?? null
}
/**
* ** `hintAt` **
* `hintAt` 2
*/
async function hasNewerSubmission(
userId: number,
problemId: number,
hintAt: string,
) {
const [row] = await db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, userId),
eq(schema.submission.problemId, problemId),
gt(schema.submission.createTime, hintAt),
),
)
.limit(1)
return row !== undefined
}
/**
*
* window function
*/
async function currentLadder(userId: number, problemId: number) {
const since = await lastAcceptedAt(userId, problemId)
const rows = await db
.select({ level: schema.aiHint.level, hintAt: schema.aiHint.createTime })
.from(schema.aiHint)
.innerJoin(
schema.submission,
eq(schema.aiHint.submissionId, schema.submission.id),
)
.where(
and(
eq(schema.submission.userId, userId),
eq(schema.submission.problemId, problemId),
gte(schema.aiHint.level, 0),
isNull(schema.aiHint.error),
since ? gt(schema.submission.createTime, since) : undefined,
),
)
if (!rows.length) return null
const level = Math.max(...rows.map((row) => row.level ?? 0))
// 锚点取这一级**最早**那条:同一级重复给过几次时,锚点不能跟着往后挪,
// 否则学生每按一次「让 AI 分析」都得多交一次才升得上去
const anchor = rows
.filter((row) => row.level === level)
.map((row) => row.hintAt)
.sort()[0]!
return { level, anchor }
}
export interface HintLevelDecision {
/** 这次要按哪一级生成。`HINT_LEVEL_COMPILE` 不在阶梯上 */
level: number
/** 生成完之后,再点一次「再多一点提示」还升不升得动 —— 直接进 done 事件 */
canEscalate: boolean
}
/**
* `submission`
*
* `canEscalate` **** false
*
*
*/
export async function decideHintLevel(
userId: number,
submission: { problemId: number; result: number; createTime: string },
more: boolean,
): Promise<HintLevelDecision> {
// 编译失败自成一档:不看阶梯、不动阶梯,也就没有「再多一点」可点
if (submission.result === JudgeStatus.COMPILE_ERROR)
return { level: HINT_LEVEL_COMPILE, canEscalate: false }
const problemId = submission.problemId
const ladder = await currentLadder(userId, problemId)
// 这道题还没开过阶梯:从 L0 起,而这条提示就是 L0 的锚点,不可能已经有更新的提交
if (!ladder) return { level: 0, canEscalate: false }
const unlocked =
ladder.level < HINT_MAX_LEVEL &&
(await hasNewerSubmission(userId, problemId, ladder.anchor))
if (more && unlocked) return { level: ladder.level + 1, canEscalate: false }
return { level: ladder.level, canEscalate: unlocked }
}
+177 -7
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract" import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import { NProgress, NText } from "naive-ui" import { NProgress, NTag, NText } from "naive-ui"
import { import {
getLearnStudents, getLearnStudents,
getLearnTutorials, getLearnTutorials,
@@ -46,19 +46,89 @@ const typeOptions = [
{ label: "C 语言", value: "c" }, { label: "C 语言", value: "c" },
] ]
type StudentStatus = "idle" | "stalled" | "noPractice" | "going" | "done"
const STALL_DAYS = 7
const STATUS_META: Record<
StudentStatus,
{ label: string; type: "default" | "error" | "warning" | "info" | "success" }
> = {
idle: { label: "未开始", type: "error" },
stalled: { label: `${STALL_DAYS} 天没学`, type: "warning" },
noPractice: { label: "只读不练", type: "info" },
going: { label: "进行中", type: "default" },
done: { label: "已学完", type: "success" },
}
//
// > > > >
function statusOf(row: LearnStudentProgress): StudentStatus {
if (row.readCount === 0 && row.totalSeconds === 0 && !row.exerciseTried) {
return "idle"
}
if (tutorialCount.value && row.readCount >= tutorialCount.value) return "done"
if (row.lastViewedAt) {
// time.ts
const days = (Date.now() - Date.parse(row.lastViewedAt)) / 86_400_000
if (days > STALL_DAYS) return "stalled"
}
if (exerciseCount.value && row.readCount > 0 && row.exerciseTried === 0) {
return "noPractice"
}
return "going"
}
const statusFilter = ref<StudentStatus | "all">("all")
const statusCounts = computed(() => {
const counts: Record<StudentStatus, number> = {
idle: 0,
stalled: 0,
noPractice: 0,
going: 0,
done: 0,
}
for (const row of students.value) counts[statusOf(row)]++
return counts
})
const startedCount = computed( const startedCount = computed(
() => students.value.filter((row) => row.readCount > 0).length, () => students.value.length - statusCounts.value.idle,
) )
const avgRead = computed(() =>
students.value.length
? (
students.value.reduce((n, row) => n + row.readCount, 0) /
students.value.length
).toFixed(1)
: "0",
)
// /
const solveRate = computed(() => {
const tried = students.value.reduce((n, row) => n + row.exerciseTried, 0)
const solved = students.value.reduce((n, row) => n + row.exerciseSolved, 0)
return tried ? Math.round((solved / tried) * 100) : null
})
function lastSeen(value: string | null) {
if (!value) return "-"
const days = Math.floor((Date.now() - Date.parse(value)) / 86_400_000)
const absolute = parseTime(value, "M月D日 HH:mm")
return days >= 1 ? `${absolute}${days} 天前)` : absolute
}
// //
// //
const filteredStudents = computed(() => { const filteredStudents = computed(() => {
const value = keyword.value.trim().toLowerCase() const value = keyword.value.trim().toLowerCase()
if (!value) return students.value
return students.value.filter( return students.value.filter(
(row) => (row) =>
(statusFilter.value === "all" || statusOf(row) === statusFilter.value) &&
(!value ||
row.username.toLowerCase().includes(value) || row.username.toLowerCase().includes(value) ||
(row.realName ?? "").toLowerCase().includes(value), (row.realName ?? "").toLowerCase().includes(value)),
) )
}) })
@@ -71,6 +141,19 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
width: 110, width: 110,
render: (row) => row.realName || "-", render: (row) => row.realName || "-",
}, },
{
title: "状态",
key: "status",
width: 110,
render: (row) => {
const meta = STATUS_META[statusOf(row)]
return h(
NTag,
{ size: "small", type: meta.type, bordered: false },
() => meta.label,
)
},
},
{ {
title: `已读(共 ${tutorialCount.value} 课)`, title: `已读(共 ${tutorialCount.value} 课)`,
key: "readCount", key: "readCount",
@@ -128,10 +211,9 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{ {
title: "最后学习", title: "最后学习",
key: "lastViewedAt", key: "lastViewedAt",
width: 170, width: 210,
sorter: "default", sorter: "default",
render: (row) => render: (row) => lastSeen(row.lastViewedAt),
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-",
}, },
]) ])
@@ -210,6 +292,31 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
ellipsis: { tooltip: true }, ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)", render: (row) => row.question || "(无题干)",
}, },
{
//
//
title: "提示",
key: "flag",
width: 100,
render: (row) => {
if (row.triedUsers < 3) return null
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
return h(
NTag,
{ size: "small", type: "warning", bordered: false },
() => "没人一次对",
)
}
if (row.solvedUsers / row.triedUsers < 0.5) {
return h(
NTag,
{ size: "small", type: "error", bordered: false },
() => "多数人卡住",
)
}
return null
},
},
{ {
title: "做对 / 做过", title: "做对 / 做过",
key: "solvedUsers", key: "solvedUsers",
@@ -258,6 +365,7 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
async function load() { async function load() {
loading.value = true loading.value = true
expanded.value = [] expanded.value = []
statusFilter.value = "all"
const params = { type: type.value, className: className.value.trim() } const params = { type: type.value, className: className.value.trim() }
try { try {
// tab // tab
@@ -312,6 +420,49 @@ onMounted(load)
</n-text> </n-text>
</n-flex> </n-flex>
<n-grid
cols="2 s:3 m:5"
:x-gap="12"
:y-gap="12"
responsive="screen"
style="margin-bottom: 16px"
>
<n-gi>
<n-card size="small" :bordered="true">
<n-statistic label="学生" :value="studentCount" />
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="已开始" :value="startedCount">
<template #suffix>/ {{ students.length }}</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="人均已读课数" :value="avgRead">
<template #suffix>/ {{ tutorialCount }}</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic
label="练一练做对率"
:value="solveRate === null ? '-' : `${solveRate}%`"
/>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="停滞(7 天没学)" :value="statusCounts.stalled">
<template #suffix></template>
</n-statistic>
</n-card>
</n-gi>
</n-grid>
<n-tabs v-model:value="tab" type="line" animated> <n-tabs v-model:value="tab" type="line" animated>
<n-tab-pane name="students" tab="按学生"> <n-tab-pane name="students" tab="按学生">
<n-flex align="center" style="margin-bottom: 12px"> <n-flex align="center" style="margin-bottom: 12px">
@@ -325,6 +476,25 @@ onMounted(load)
找到 {{ filteredStudents.length }} 找到 {{ filteredStudents.length }}
</n-text> </n-text>
</n-flex> </n-flex>
<n-flex :size="8" style="margin-bottom: 12px">
<n-tag
checkable
:checked="statusFilter === 'all'"
@update:checked="statusFilter = 'all'"
>
全部 {{ students.length }}
</n-tag>
<n-tag
v-for="(meta, key) in STATUS_META"
:key="key"
checkable
:type="meta.type"
:checked="statusFilter === key"
@update:checked="statusFilter = statusFilter === key ? 'all' : key"
>
{{ meta.label }} {{ statusCounts[key] }}
</n-tag>
</n-flex>
<n-data-table <n-data-table
:loading="loading" :loading="loading"
:columns="studentColumns" :columns="studentColumns"
@@ -16,7 +16,7 @@ const emit = defineEmits<{
(e: "update:modelValue", value: AstRules | null): void (e: "update:modelValue", value: AstRules | null): void
}>() }>()
// C / Python3judge/ast.ts // C / Pythonjudge/ast.ts
// loadLanguage null tab // loadLanguage null tab
// C++ // C++
const supportedLanguages = computed(() => const supportedLanguages = computed(() =>
@@ -26,7 +26,7 @@ const unsupportedLanguages = computed(() =>
props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)), props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)),
) )
const activeTab = ref(supportedLanguages.value[0] || "Python3") const activeTab = ref(supportedLanguages.value[0] || "Python")
const ENGINE_OPTIONS: SelectOption[] = [ const ENGINE_OPTIONS: SelectOption[] = [
{ {
@@ -47,7 +47,7 @@ function makeInitialFiles(): FileEntry[] {
const files = ref<FileEntry[]>(makeInitialFiles()) const files = ref<FileEntry[]>(makeInitialFiles())
const selectedLanguage = ref<LANGUAGE>("Python3") const selectedLanguage = ref<LANGUAGE>("Python")
// //
const availableLanguages = computed(() => const availableLanguages = computed(() =>
+8 -8
View File
@@ -63,7 +63,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
difficulty: "Low", difficulty: "Low",
visible: false, visible: false,
tags: [], tags: [],
languages: ["Python3", "C"] as LANGUAGE[], languages: ["Python", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string }, template: {} as { [key in LANGUAGE]?: string },
samples: [ samples: [
{ input: "", output: "" }, { input: "", output: "" },
@@ -153,8 +153,8 @@ function validateNewTags(v: string[]) {
// //
const [needTemplate, toggleNeedTemplate] = useToggle(false) const [needTemplate, toggleNeedTemplate] = useToggle(false)
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES))) const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
const currentActiveTemplate = ref<LANGUAGE>("Python3") const currentActiveTemplate = ref<LANGUAGE>("Python")
const currentActiveAnswer = ref<LANGUAGE>("Python3") const currentActiveAnswer = ref<LANGUAGE>("Python")
// TextEditor // TextEditor
const [ready, toggleReady] = useToggle(false) const [ready, toggleReady] = useToggle(false)
@@ -169,7 +169,7 @@ const difficultyOptions: SelectOption[] = [
] ]
const languageOptions = [ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" }, { label: LANGUAGE_SHOW_VALUE["Python"], value: "Python" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" }, { label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" }, { label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" }, { label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
@@ -511,7 +511,7 @@ async function generateMermaid() {
isAIGenerating.value = true isAIGenerating.value = true
try { try {
const res = await generateFlowchartFromPythonCode( const res = await generateFlowchartFromPythonCode(
problem.value.answers.filter((a) => a.language === "Python3")[0].code, problem.value.answers.filter((a) => a.language === "Python")[0].code,
) )
problem.value.mermaidCode = res.flowchart problem.value.mermaidCode = res.flowchart
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法") message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
@@ -736,7 +736,7 @@ watch(
> >
<n-tabs <n-tabs
type="segment" type="segment"
default-value="Python3" default-value="Python"
v-model:value="currentActiveAnswer" v-model:value="currentActiveAnswer"
> >
<n-tab-pane <n-tab-pane
@@ -760,7 +760,7 @@ watch(
<n-form-item label="编写预制代码"> <n-form-item label="编写预制代码">
<n-tabs <n-tabs
type="segment" type="segment"
default-value="Python3" default-value="Python"
v-model:value="currentActiveTemplate" v-model:value="currentActiveTemplate"
> >
<n-tab-pane <n-tab-pane
@@ -878,7 +878,7 @@ watch(
type="primary" type="primary"
size="small" size="small"
:disabled=" :disabled="
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code !problem.answers.filter((a) => a.language === 'Python')[0]?.code
.length .length
" "
:loading="isAIGenerating" :loading="isAIGenerating"
@@ -391,7 +391,7 @@ function typeTagType(type: ExerciseType) {
{{ typeName(ex.type) }} {{ typeName(ex.type) }}
</n-tag> </n-tag>
<n-text style="margin-left: 10px"> <n-text style="margin-left: 10px">
{{ (ex.data as any).question }} {{ (ex.data as { question?: string }).question }}
</n-text> </n-text>
</div> </div>
<n-space :size="8"> <n-space :size="8">
+1 -1
View File
@@ -107,7 +107,7 @@ onMounted(init)
<n-tab-pane name="code" tab="示例代码"> <n-tab-pane name="code" tab="示例代码">
<CodeEditor <CodeEditor
v-model:value="tutorial.code" v-model:value="tutorial.code"
:language="tutorial.type === 'python' ? 'Python3' : 'C'" :language="tutorial.type === 'python' ? 'Python' : 'C'"
height="400px" height="400px"
/> />
</n-tab-pane> </n-tab-pane>
+9 -1
View File
@@ -1,5 +1,6 @@
import { import {
type AiAnalysisRecord, type AiAnalysisRecord,
type AiHintFeedbackRequest,
type Contest as OjContest, type Contest as OjContest,
type ContestAccess, type ContestAccess,
type ContestList, type ContestList,
@@ -130,7 +131,7 @@ export function submitCode(data: SubmitCodePayload) {
export function formatCode(data: { code: string; language: string }) { export function formatCode(data: { code: string; language: string }) {
const languages: Record<string, string> = { const languages: Record<string, string> = {
Python3: "python", Python: "python",
C: "c", C: "c",
"C++": "cpp", "C++": "cpp",
SQL: "sql", SQL: "sql",
@@ -380,6 +381,13 @@ export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned") return api.get<AiAnalysisRecord | null>("ai/pinned")
} }
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, {
helpful,
} satisfies AiHintFeedbackRequest)
}
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
+7 -3
View File
@@ -26,6 +26,7 @@ import {
Legend, Legend,
Colors, Colors,
Filler, Filler,
type TooltipItem,
} from "chart.js" } from "chart.js"
// Chart.js // Chart.js
@@ -673,9 +674,12 @@ const radarChartOptions = {
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: function (context: any) { label: function (context: TooltipItem<"radar">) {
const dataset = context.dataset as any // rawData dataset chart.js
const rawValue = dataset?.rawData?.[context.dataIndex] const dataset = context.dataset as typeof context.dataset & {
rawData?: (number | null)[]
}
const rawValue = dataset.rawData?.[context.dataIndex]
const metric = context.label || "" const metric = context.label || ""
const isRate = context.dataIndex >= 3 const isRate = context.dataIndex >= 3
if (rawValue === undefined || rawValue === null) { if (rawValue === undefined || rawValue === null) {
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types"
const props = defineProps<{
titles: { id: number; title: string }[]
progress: Record<number, TutorialProgress>
traced: boolean
}>()
const stats = computed(() => {
const rows = props.titles.map((t) => props.progress[t.id])
const read = rows.filter(
(p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS,
).length
const solved = rows.reduce((n, p) => n + (p?.exerciseSolved ?? 0), 0)
const total = rows.reduce((n, p) => n + (p?.exerciseTotal ?? 0), 0)
return { read, solved, total }
})
const percent = computed(() =>
props.titles.length
? Math.round((stats.value.read / props.titles.length) * 100)
: 0,
)
</script>
<template>
<div v-if="traced && titles.length" class="summary">
<n-progress
type="line"
:percentage="percent"
:height="8"
:show-indicator="false"
status="success"
/>
<n-text depth="3" class="numbers">
已读 {{ stats.read }}/{{ titles.length }}
<template v-if="stats.total">
· 练一练 {{ stats.solved }}/{{ stats.total }}
</template>
</n-text>
</div>
</template>
<style scoped>
.summary {
padding: 4px 10px 12px;
}
.numbers {
display: block;
margin-top: 6px;
font-size: 12px;
}
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Segment } from "../composables/useExerciseParse"
defineProps<{ segments: Segment[]; lang?: string }>()
const isDark = useDark()
const ExerciseWidget = defineAsyncComponent(
() => import("./ExerciseWidget.vue"),
)
</script>
<template>
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget v-else :exercise="seg.exercise" :lang="lang" />
</template>
</template>
+101 -58
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract" import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types" import type { TutorialProgress } from "utils/types"
import { readableDuration } from "utils/functions"
defineProps<{ const props = defineProps<{
titles: { id: number; title: string }[] titles: { id: number; title: string }[]
step: number step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */ /** 按教程 id 索引的自学留痕,未登录时是空的 */
@@ -14,70 +13,114 @@ defineProps<{
const emit = defineEmits<{ select: [lesson: number] }>() const emit = defineEmits<{ select: [lesson: number] }>()
// readableDuration "-" - type Status = "todo" | "reading" | "done"
// 15 0
function readSoFar(seconds: number) { /**
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟" * 三态没打开过 / 读过但没读满或练习没做完 / 读满且练习全对
* 没有练习的课只看阅读已读的门槛沿用契约的 TUTORIAL_READ_SECONDS
*/
function statusOf(id: number): Status {
const p = props.progress[id]
if (!p?.viewCount) return "todo"
const read = p.totalSeconds >= TUTORIAL_READ_SECONDS
const practiced = !p.exerciseTotal || p.exerciseSolved >= p.exerciseTotal
return read && practiced ? "done" : "reading"
}
function hint(id: number) {
const p = props.progress[id]
if (!p?.exerciseTotal) return ""
return `练一练 ${p.exerciseSolved}/${p.exerciseTotal}`
} }
</script> </script>
<template> <template>
<n-list hoverable clickable> <ol class="lessons">
<n-list-item <li
v-for="(item, index) in titles" v-for="(item, index) in titles"
:key="item.id" :key="item.id"
class="lesson"
:class="{ active: step === index + 1 }"
@click="emit('select', index + 1)" @click="emit('select', index + 1)"
> >
<!-- 标题独占一行目录栏只有屏幕的五分之一宽已读摆在同一行会把 <span class="dot" :class="traced ? statusOf(item.id) : 'todo'">
中文标题挤成两截 --> <template v-if="traced && statusOf(item.id) === 'done'"></template>
<n-flex vertical :size="2"> <template v-else>{{ index + 1 }}</template>
<n-text </span>
:type="step === index + 1 ? 'primary' : undefined" <span class="text">
:strong="step === index + 1" <span class="title">{{ item.title }}</span>
> <span v-if="traced && hint(item.id)" class="hint">
{{ index + 1 }}. {{ item.title }} {{ hint(item.id) }}
</n-text> </span>
<!-- 每篇教程都有一条进度没读过的是一行零所以这里判的是读没读过 </span>
不是有没有这条记录 </li>
TUTORIAL_READ_SECONDS 才打 打开过但没读满的仍然显示时长 </ol>
只是不带勾也不是成功色 记是记下了还没到已读 --> <n-text v-if="!traced" depth="3" class="login-tip">
<n-text
v-if="progress[item.id]?.totalSeconds >= TUTORIAL_READ_SECONDS"
type="success"
style="font-size: 12px"
>
已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-else-if="progress[item.id]?.viewCount"
depth="3"
style="font-size: 12px"
>
读了 {{ readSoFar(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-if="progress[item.id]?.exerciseTotal"
:type="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? 'success'
: undefined
"
:depth="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? undefined
: 3
"
style="font-size: 12px"
>
练一练 {{ progress[item.id].exerciseSolved }} /
{{ progress[item.id].exerciseTotal }}
</n-text>
</n-flex>
</n-list-item>
</n-list>
<!-- 只在没登录时提一句登录了却还没读的人不需要被提醒你还没读 -->
<n-text v-if="!traced" depth="3" style="display: block; padding: 8px 4px">
登录后可以记录学习进度 登录后可以记录学习进度
</n-text> </n-text>
</template> </template>
<style scoped>
.lessons {
list-style: none;
margin: 0;
padding: 0;
}
.lesson {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s;
}
.lesson:hover {
background: rgba(128, 128, 128, 0.12);
}
.lesson.active {
background: rgba(24, 160, 88, 0.14);
}
.dot {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
border: 1.5px solid rgba(128, 128, 128, 0.5);
}
.dot.reading {
border-color: #f0a020;
color: #f0a020;
}
.dot.done {
border-color: #18a058;
background: #18a058;
color: #fff;
}
.active .dot.todo {
border-color: #18a058;
color: #18a058;
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
line-height: 1.4;
}
.active .title {
font-weight: 600;
}
.hint {
font-size: 12px;
opacity: 0.6;
}
.login-tip {
display: block;
padding: 8px 10px;
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
defineProps<{ step: number; total: number }>()
const theme = useThemeVars()
const emit = defineEmits<{ go: [lesson: number] }>()
</script>
<template>
<nav class="pager" :style="{ background: theme.bodyColor }">
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)">
上一课
</n-button>
<n-text depth="3">{{ step }} / {{ total }}</n-text>
<n-button
type="primary"
:secondary="step >= total"
:disabled="step >= total"
@click="emit('go', step + 1)"
>
下一课
</n-button>
</nav>
</template>
<style scoped>
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
margin-top: 16px;
position: sticky;
bottom: 0;
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
</style>
@@ -1,6 +1,6 @@
import type { Exercise } from "utils/types" import type { Exercise } from "utils/types"
type Segment = export type Segment =
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise } { type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
export function parseExercises( export function parseExercises(
+84 -104
View File
@@ -1,14 +1,18 @@
<template> <template>
<div class="learn-container"> <div class="learn-container">
<!-- 桌面端布局 --> <template v-if="tutorial.id">
<n-grid <!-- 桌面端目录 | 正文居中限宽 | 可收起的示例代码 -->
:cols="5" <div
:x-gap="16" v-if="isDesktop"
v-if="tutorial.id && isDesktop" class="learn-layout"
class="learn-grid" :class="{ 'with-code': codeOpen }"
> >
<n-gi :span="1" class="learn-col"> <aside class="rail">
<n-card title="教程目录" :bordered="false" size="small"> <LearnSummary
:titles="titles"
:progress="progress"
:traced="traced"
/>
<LessonList <LessonList
:titles="titles" :titles="titles"
:step="step" :step="step"
@@ -16,50 +20,41 @@
:traced="traced" :traced="traced"
@select="goToLesson" @select="goToLesson"
/> />
</n-card> </aside>
</n-gi>
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col"> <main class="reader">
<n-card <article class="reader-body">
:title="`第 ${step} 课:${titles[step - 1]?.title}`" <header class="lesson-head">
:bordered="false" <n-text depth="3"> {{ step }} / {{ titles.length }} </n-text>
<n-flex align="center" justify="space-between" :wrap="false">
<span />
<n-button
v-if="tutorial.code"
size="small" size="small"
secondary
@click="codeOpen = !codeOpen"
> >
<template v-for="(seg, i) in segments" :key="i"> {{ codeOpen ? "收起示例代码" : "展开示例代码" }}
<MdPreview </n-button>
v-if="seg.type === 'md'" </n-flex>
preview-theme="vuepress" </header>
:theme="isDark ? 'dark' : 'light'" <LessonBody :segments="segments" :lang="tutorial.type" />
:model-value="seg.content" </article>
/> <PagerBar :step="step" :total="titles.length" @go="goToLesson" />
<ExerciseWidget </main>
v-else
:exercise="seg.exercise"
:lang="tutorial.type"
/>
</template>
</n-card>
</n-gi>
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code"> <aside v-if="tutorial.code && codeOpen" class="code-panel">
<n-card
title="示例代码"
:bordered="false"
size="small"
class="code-card"
content-style="height: calc(100% - 44px); padding: 0;"
>
<CodeEditor <CodeEditor
:language="editorLanguage" :language="editorLanguage"
v-model="tutorial.code" v-model="tutorial.code"
height="100%" height="100%"
/> />
</n-card> </aside>
</n-gi> </div>
</n-grid>
<!-- 手机端布局 --> <!-- 手机端 -->
<template v-if="tutorial.id && !isDesktop"> <template v-else>
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<n-tabs type="line" animated v-model:value="activeTab"> <n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录"> <n-tab-pane name="catalog" tab="目录">
<LessonList <LessonList
@@ -70,49 +65,15 @@
@select="goToLesson" @select="goToLesson"
/> />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`"> <n-tab-pane name="content" :tab="`第 ${step} 课`">
<template v-for="(seg, i) in segments" :key="i"> <LessonBody :segments="segments" :lang="tutorial.type" />
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="tutorial.type"
/>
</template>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code"> <n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" /> <CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
<n-divider style="margin: 12px 0" /> </template>
<n-flex align="center" justify="space-between">
<n-button
secondary
type="primary"
:disabled="isFirstLesson"
@click="goToPrevLesson"
>
上一课
</n-button>
<n-text>{{ step }} / {{ titles.length }}</n-text>
<n-button
secondary
type="primary"
:disabled="isLastLesson"
@click="goToNextLesson"
>
下一课
</n-button>
</n-flex>
</template> </template>
<n-empty <n-empty
@@ -124,8 +85,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { import type {
Tutorial, Tutorial,
Exercise, Exercise,
@@ -144,15 +103,13 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress" import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue" import LessonList from "./components/LessonList.vue"
import LearnSummary from "./components/LearnSummary.vue"
const ExerciseWidget = defineAsyncComponent( import LessonBody from "./components/LessonBody.vue"
() => import("./components/ExerciseWidget.vue"), import PagerBar from "./components/PagerBar.vue"
)
const CodeEditor = defineAsyncComponent( const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"), () => import("shared/components/CodeEditor.vue"),
) )
const isDark = useDark()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -180,12 +137,14 @@ const tutorial = ref<Partial<Tutorial>>({
}) })
const editorLanguage = computed<LANGUAGE>(() => const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3", tutorial.value.type === "c" ? "C" : "Python",
) )
const titles = ref<{ id: number; title: string }[]>([]) const titles = ref<{ id: number; title: string }[]>([])
const progress = ref<Record<number, TutorialProgress>>({}) const progress = ref<Record<number, TutorialProgress>>({})
const exercises = ref<Exercise[]>([]) const exercises = ref<Exercise[]>([])
const activeTab = ref("content") const activeTab = ref("content")
//
const codeOpen = useStorage("oj2:learn-code-open", true)
const isEmpty = ref(false) const isEmpty = ref(false)
const segments = computed(() => const segments = computed(() =>
@@ -198,22 +157,12 @@ useLearnTrace(
traced, traced,
) )
const isFirstLesson = computed(() => step.value === 1)
const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) { function goToLesson(lessonNumber: number) {
activeTab.value = "content" activeTab.value = "content"
router.push( router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`, `/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
) )
} }
function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1)
}
function goToNextLesson() {
if (step.value < titles.value.length) goToLesson(step.value + 1)
}
/** /**
* 拉自己的自学留痕给目录打勾失败就当没有 目录少几个勾不影响上课 * 拉自己的自学留痕给目录打勾失败就当没有 目录少几个勾不影响上课
* 但弹个错会把我是不是没学的焦虑塞给学生 * 但弹个错会把我是不是没学的焦虑塞给学生
@@ -263,27 +212,58 @@ watch(traced, loadProgress)
</script> </script>
<style scoped> <style scoped>
/* 桌面端固定高度,目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */ /* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
@media (min-width: 769px) { @media (min-width: 769px) {
.learn-container { .learn-container {
height: calc(100vh - 138px); height: calc(100vh - 138px);
} }
} }
.learn-grid { .learn-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 24px;
height: 100%; height: 100%;
} }
.learn-layout.with-code {
grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 40%);
}
.learn-col { .rail,
.reader {
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
} }
.reader {
.learn-col--code { display: flex;
overflow-y: hidden; flex-direction: column;
}
.reader-body {
flex: 1;
width: 100%;
max-width: 820px;
margin: 0 auto;
}
.reader :deep(.pager) {
max-width: 820px;
width: 100%;
margin-left: auto;
margin-right: auto;
} }
.code-card { .lesson-head h1,
.mobile-title {
margin: 4px 0 12px;
font-size: 26px;
line-height: 1.3;
}
.mobile-title {
font-size: 20px;
}
.code-panel {
height: 100%; height: 100%;
overflow: hidden;
border-radius: 8px;
} }
</style> </style>
@@ -7,6 +7,7 @@ import CodeEditor from "shared/components/CodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue" import Form from "./Form.vue"
const route = useRoute() const route = useRoute()
@@ -34,6 +35,10 @@ onMounted(() => {
problem.value!.template[codeStore.code.language] || problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language], SOURCES[codeStore.code.language],
) )
beginEditTrace(
`problem_${problem.value!._id}_contest_${contestID}`,
codeStore.code.value.length,
)
}) })
const changeCode = (v: string) => { const changeCode = (v: string) => {
@@ -58,6 +63,7 @@ const changeLanguage = (v: LANGUAGE) => {
v-model:value="codeStore.code.value" v-model:value="codeStore.code.value"
:language="codeStore.code.language" :language="codeStore.code.language"
:height="editorHeight" :height="editorHeight"
:extra-extensions="editTraceExtensions"
@update:model-value="changeCode" @update:model-value="changeCode"
/> />
</n-flex> </n-flex>
+2 -2
View File
@@ -254,8 +254,8 @@ const goEdit = () => {
onMounted(() => { onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) { if (!languages.value.includes(codeStore.code.language)) {
// 退 SQL "SQL" Python3 // 退 SQL "SQL" Python
codeStore.code.language = languages.value[0] ?? "Python3" codeStore.code.language = languages.value[0] ?? "Python"
} }
}) })
</script> </script>
@@ -5,8 +5,7 @@ import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants" import type { Problem, ProblemFiltered, ProblemStatus } from "utils/types"
import type { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -46,11 +45,14 @@ const sqlChangedTables = computed(() => {
const router = useRouter() const router = useRouter()
// //
const similarProblems = ref<any[]>([]) const similarProblems = ref<ProblemFiltered[]>([])
const similarLoaded = ref(false) const similarLoaded = ref(false)
async function loadSimilarProblems() { async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return if (similarLoaded.value || !problem.value) return
// displayId **** 1/2/3
// 404
if (problem.value.contestId !== null) return
try { try {
similarProblems.value = await getSimilarProblems(problem.value._id) similarProblems.value = await getSimilarProblems(problem.value._id)
} catch { } catch {
@@ -407,19 +409,19 @@ function type(status: ProblemStatus) {
{{ sp.title }} {{ sp.title }}
</n-button> </n-button>
</n-flex> </n-flex>
<!-- getSimilarProblems 已经过 filterResult难度是中文不是 Low/Mid/High -->
<n-tag <n-tag
v-if="sp.difficulty"
size="small" size="small"
:type=" :type="
sp.difficulty === 'Low' sp.difficulty === '简单'
? 'success' ? 'success'
: sp.difficulty === 'High' : sp.difficulty === '困难'
? 'error' ? 'error'
: 'warning' : 'warning'
" "
> >
{{ {{ sp.difficulty }}
DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等"
}}
</n-tag> </n-tag>
</n-flex> </n-flex>
</n-list-item> </n-list-item>
@@ -8,6 +8,7 @@ import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue" import Form from "./Form.vue"
const FlowchartEditor = defineAsyncComponent( const FlowchartEditor = defineAsyncComponent(
@@ -98,6 +99,11 @@ function loadCode() {
problem.value!.template[codeStore.code.language] || problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language], SOURCES[codeStore.code.language],
) )
// loadCode稿
beginEditTrace(
`problem_${problem.value!._id}_contest_${contestID}`,
codeStore.code.value.length,
)
} }
onMounted(loadCode) onMounted(loadCode)
@@ -151,6 +157,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
:language="codeStore.code.language" :language="codeStore.code.language"
:problem-id="problem!._id" :problem-id="problem!._id"
:height="editorHeight" :height="editorHeight"
:extra-extensions="editTraceExtensions"
@update:model-value="changeCode" @update:model-value="changeCode"
/> />
</n-flex> </n-flex>
@@ -298,7 +298,7 @@ watch(query, listSubmissions)
<n-tag <n-tag
v-for="item in statusDistribution" v-for="item in statusDistribution"
:key="item.result" :key="item.result"
:type="item.type as any" :type="item.type"
size="small" size="small"
round round
> >
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { HINT_MIN_FAILURES } from "@oj2/contract" import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract"
import type { JudgeCaseResult } from "@oj2/contract" import type { JudgeCaseResult } from "@oj2/contract"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { import {
@@ -15,6 +15,7 @@ import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { aiStreamError, consumeJSONEventStream } from "utils/stream" import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { submitHintFeedback } from "oj/api"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
@@ -27,10 +28,64 @@ const isDark = useDark()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const theme = useThemeVars() const theme = useThemeVars()
// AI // AI
// hintTarget hintContent ""
// 2c 2.6
//
const hintTarget = ref("")
const hintContent = ref("") const hintContent = ref("")
const hintLoading = ref(false) const hintLoading = ref(false)
const hintError = ref("") const hintError = ref("")
// ai_hint id done
const hintId = ref<number | null>(null)
// -1 =
// done
const hintLevel = ref<number | null>(null)
const hintCanEscalate = ref(false)
const hintHelpful = ref<boolean | null>(null)
const hintFeedbackSending = ref(false)
// 24ms 3 125 /
// MdPreview Markdown
const TYPE_STEP = 3
const TYPE_INTERVAL = 24
let typingTimer: ReturnType<typeof setInterval> | null = null
const hintTyping = computed(
() => hintContent.value.length < hintTarget.value.length,
)
function stopTyping() {
if (typingTimer === null) return
clearInterval(typingTimer)
typingTimer = null
}
function startTyping() {
if (typingTimer !== null) return
typingTimer = setInterval(() => {
if (!hintTyping.value) {
stopTyping()
return
}
hintContent.value = hintTarget.value.slice(
0,
hintContent.value.length + TYPE_STEP,
)
}, TYPE_INTERVAL)
}
function resetHint() {
stopTyping()
hintTarget.value = ""
hintContent.value = ""
hintError.value = ""
hintId.value = null
hintLevel.value = null
hintCanEscalate.value = false
hintHelpful.value = null
}
onUnmounted(stopTyping)
// //
const msg = computed(() => { const msg = computed(() => {
@@ -92,22 +147,22 @@ const showAIHint = computed(() => {
watch( watch(
() => props.submission?.id, () => props.submission?.id,
() => { () => {
hintContent.value = "" resetHint()
hintError.value = ""
hintLoading.value = false hintLoading.value = false
}, },
) )
async function fetchHint(submissionId: string) { // more =
// done level
async function fetchHint(submissionId: string, more = false) {
hintLoading.value = true hintLoading.value = true
hintContent.value = "" resetHint()
hintError.value = ""
try { try {
const response = await fetch("/api/ai/hint", { const response = await fetch("/api/ai/hint", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ submissionId }), body: JSON.stringify({ submissionId, more }),
}) })
if (!response.ok) throw await aiStreamError(response) if (!response.ok) throw await aiStreamError(response)
@@ -117,9 +172,17 @@ async function fetchHint(submissionId: string) {
type: string type: string
content?: string content?: string
message?: string message?: string
hintId?: number | null
level?: number
canEscalate?: boolean
}) => { }) => {
if (data.type === "delta" && data.content) { if (data.type === "delta" && data.content) {
hintContent.value += data.content hintTarget.value += data.content
startTyping()
} else if (data.type === "done") {
hintId.value = data.hintId ?? null
hintLevel.value = data.level ?? null
hintCanEscalate.value = data.canEscalate === true
} else if (data.type === "error") { } else if (data.type === "error") {
hintError.value = data.message || "AI 提示生成失败" hintError.value = data.message || "AI 提示生成失败"
} }
@@ -132,6 +195,22 @@ async function fetchHint(submissionId: string) {
} }
} }
//
//
async function sendHintFeedback(helpful: boolean) {
if (hintId.value === null || hintFeedbackSending.value) return
if (hintHelpful.value === helpful) return
hintFeedbackSending.value = true
try {
await submitHintFeedback(hintId.value, helpful)
hintHelpful.value = helpful
} catch {
//
} finally {
hintFeedbackSending.value = false
}
}
// //
const infoTable = computed(() => { const infoTable = computed(() => {
const submission = props.submission const submission = props.submission
@@ -241,19 +320,64 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
class="mb-3" class="mb-3"
/> />
<n-button <n-button
v-if="!hintContent && !hintLoading" v-if="!hintTarget && !hintLoading"
type="primary" type="primary"
@click="fetchHint(submission.id)" @click="fetchHint(submission.id)"
> >
AI 分析我的代码 AI 分析我的代码
</n-button> </n-button>
<n-spin v-else-if="hintLoading && !hintContent" size="small" /> <n-spin v-else-if="hintLoading && !hintTarget" size="small" />
<MdPreview <MdPreview
v-if="hintContent" v-if="hintContent"
:model-value="hintContent" :model-value="hintContent"
preview-theme="vuepress" preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
<!-- 等级和再多一点提示按钮出不出由后端 done 里的 canEscalate
前端不自己推阶梯要再交一次才升得动规则在 services/hint-level.ts -->
<n-flex
v-if="hintLevel !== null && !hintLoading && !hintTyping"
align="center"
size="small"
style="margin-top: 8px"
>
<n-tag size="small" :bordered="false">
{{ hintLevelLabel(hintLevel) }}
</n-tag>
<n-button
v-if="hintCanEscalate"
size="tiny"
type="primary"
ghost
@click="fetchHint(submission.id, true)"
>
再多一点提示
</n-button>
</n-flex>
<n-flex
v-if="hintId !== null && !hintLoading && !hintTyping"
align="center"
size="small"
style="margin-top: 8px"
>
<n-text depth="3">这条提示对你有帮助吗</n-text>
<n-button
size="tiny"
:type="hintHelpful === true ? 'primary' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(true)"
>
有帮助
</n-button>
<n-button
size="tiny"
:type="hintHelpful === false ? 'warning' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(false)"
>
没帮助
</n-button>
</n-flex>
</n-card> </n-card>
</template> </template>
</div> </div>
@@ -12,6 +12,8 @@ import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState" import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { useCollabStore } from "shared/store/collab"
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
import { import {
checkPythonSyntax, checkPythonSyntax,
prefetchPythonSyntaxChecker, prefetchPythonSyntaxChecker,
@@ -24,6 +26,7 @@ const ProblemReaction = defineAsyncComponent(
// ==================== ==================== // ==================== ====================
const userStore = useUserStore() const userStore = useUserStore()
const collabStore = useCollabStore()
const codeStore = useCodeStore() const codeStore = useCodeStore()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
@@ -53,11 +56,11 @@ const isFormatting = ref(false)
const isSubmittingRequest = ref(false) const isSubmittingRequest = ref(false)
// ==================== Python ==================== // ==================== Python ====================
// Python3 Skulpt // Python Skulpt
watch( watch(
() => codeStore.code.language, () => codeStore.code.language,
(language) => { (language) => {
if (language === "Python3") prefetchPythonSyntaxChecker() if (language === "Python") prefetchPythonSyntaxChecker()
}, },
{ immediate: true }, { immediate: true },
) )
@@ -111,8 +114,8 @@ const buttonState = computed(() =>
async function submit() { async function submit() {
if (buttonState.value.disabled) return if (buttonState.value.disabled) return
// 0. Python3 // 0. Python
if (codeStore.code.language === "Python3") { if (codeStore.code.language === "Python") {
const syntaxError = await checkPythonSyntax(codeStore.code.value) const syntaxError = await checkPythonSyntax(codeStore.code.value)
if (syntaxError) { if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`) message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
@@ -120,7 +123,7 @@ async function submit() {
} }
} }
// 0.5 Python3 ruffC/C++ clang-formatSQL sqlparse // 0.5 Python ruffC/C++ clang-formatSQL sqlparse
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language] const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
if (["python", "c", "cpp", "sql"].includes(formatLang)) { if (["python", "c", "cpp", "sql"].includes(formatLang)) {
isFormatting.value = true isFormatting.value = true
@@ -132,7 +135,7 @@ async function submit() {
codeStore.setCode(res.code) codeStore.setCode(res.code)
} catch (e: any) { } catch (e: any) {
if (e?.error === "format-error") { if (e?.error === "format-error") {
// Python3 // Python
message.warning(`代码格式化失败:${e.data},请检查代码后重试`) message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
return return
} }
@@ -147,6 +150,11 @@ async function submit() {
problemId: problem.value!.id, problemId: problem.value!.id,
language: codeStore.code.language, language: codeStore.code.language,
code: codeStore.code.value, code: codeStore.code.value,
// utils/editTrace.ts ProblemEditor collabHere
trace: snapshotEditTrace(
collabStore.room !== null &&
collabStore.room.problemId === problem.value!._id,
),
} }
if (contestID) { if (contestID) {
data.contestId = parseInt(contestID) data.contestId = parseInt(contestID)
@@ -161,6 +169,8 @@ async function submit() {
try { try {
const res = await submitCode(data) const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`) console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
// /
restartEditTrace(codeStore.code.value.length)
// 3. + // 3. +
startCooldown() startCooldown()
+139
View File
@@ -0,0 +1,139 @@
import { EditorView } from "@codemirror/view"
import type { SubmissionTrace } from "@oj2/contract"
/**
* `submission_trace`
* `submissionTraceSchema`****
*
* Pinia storeProblemEditor / ContestEditor
* Form SubmitCode CodeMirror
* store facet
*
*
* ** userEvent **
* - `codeStore.setCode()` vue-codemirror setDoc dispatch changes
* 稿 /
* - y-codemirror.next ySyncAnnotation
*
* / `move.drop`
*/
/** 两次编辑间隔超过这个就算走开了,中间这段不计入活跃时长 */
const IDLE_MS = 60_000
let key: string | null = null
let startedAt = 0
let lastEditAt: number | null = null
let activeMs = 0
let typedChars = 0
let pastedChars = 0
let pasteCount = 0
let maxPaste = 0
let deletedChars = 0
let blurCount = 0
let initialLen = 0
function reset(len: number) {
startedAt = performance.now()
lastEditAt = null
activeMs = 0
typedChars = 0
pastedChars = 0
pasteCount = 0
maxPaste = 0
deletedChars = 0
blurCount = 0
initialLen = len
}
/**
*
*
* `traceKey` ****
*
* key
*/
export function beginEditTrace(traceKey: string, len: number) {
if (traceKey === key) return
key = traceKey
reset(len)
}
/** 这一段的快照,附在提交请求上 */
export function snapshotEditTrace(collab: boolean): SubmissionTrace {
return {
activeMs: Math.round(activeMs),
sinceOpenMs: Math.round(performance.now() - startedAt),
typedChars,
pastedChars,
pasteCount,
maxPaste,
deletedChars,
blurCount,
initialLen,
collab,
}
}
/** 提交成功之后调:下一条提交只记从这里往后的那一段 */
export function restartEditTrace(len: number) {
reset(len)
}
function touch() {
const now = performance.now()
if (lastEditAt !== null && now - lastEditAt <= IDLE_MS)
activeMs += now - lastEditAt
lastEditAt = now
}
// 只数 hidden 这一个事件:切标签页时 window 的 blur 和 visibilitychange 会一起触发,
// 两个都数就是一次记两下。代价是同屏切到别的窗口(页面仍可见)不计,这本来就只是辅助信号。
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") return
blurCount++
// 切走的这段不算活跃,回来之后的第一下编辑重新起算
lastEditAt = null
})
/** 挂到题目页的代码编辑器上。同一个实例,别每次渲染新建 —— 那会让编辑器反复重配扩展 */
export const editTraceExtensions = [
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
for (const tr of update.transactions) {
if (!tr.docChanged) continue
// 顺序要紧:isUserEvent("input") 也会匹配 "input.paste"
const pasted =
tr.isUserEvent("input.paste") || tr.isUserEvent("input.drop")
const typed = !pasted && tr.isUserEvent("input")
const deleted = tr.isUserEvent("delete")
if (!pasted && !typed && !deleted) continue
let inserted = 0
let removed = 0
tr.changes.iterChanges((fromA, toA, _fromB, _toB, text) => {
// 原样替换不算:closeBrackets 越过已有的右括号 / 引号时,是把 `)` 替换成 `)`
// 而不是只挪光标(@codemirror/autocomplete 的 handleClose),不排掉的话
// 每敲一个右括号就多记一个键入加一个删除
if (
toA - fromA === text.length &&
tr.startState.sliceDoc(fromA, toA) === text.toString()
)
return
removed += toA - fromA
inserted += text.length
})
// 选中一段再打字 / 粘贴,被替换掉的那部分也算删除
deletedChars += removed
if (pasted) {
pastedChars += inserted
pasteCount++
if (inserted > maxPaste) maxPaste = inserted
} else if (typed) {
typedChars += inserted
}
touch()
}
}),
]
@@ -5,7 +5,7 @@ export interface PythonSyntaxError {
let skulptPromise: Promise<any> | null = null let skulptPromise: Promise<any> | null = null
/** /**
* Skulpt 233KB gzip Python3 * Skulpt 233KB gzip Python
* *
*/ */
function loadSkulpt(): Promise<any> { function loadSkulpt(): Promise<any> {
+4 -1
View File
@@ -1,3 +1,4 @@
import { normalizeLanguage } from "@oj2/contract"
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
@@ -11,7 +12,9 @@ export const useCodeStore = defineStore("code", () => {
// ==================== 状态 ==================== // ==================== 状态 ====================
const code = reactive<Code>({ const code = reactive<Code>({
value: "", value: "",
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3", // 过一道 normalizeLanguage:上线那一刻学生浏览器的 localStorage 里存的还是
// 旧值 Python3,直接拿来用会被后端的契约挡掉(而且报错看不出是这个原因)
language: normalizeLanguage(storage.get(STORAGE_KEY.LANGUAGE)) ?? "Python",
}) })
const input = ref("") const input = ref("")
+1 -1
View File
@@ -147,7 +147,7 @@ const gradeOptions: SelectOption[] = [
const languageOptions: SelectOption[] = [ const languageOptions: SelectOption[] = [
{ label: "流程图", value: "Flowchart" }, { label: "流程图", value: "Flowchart" },
{ label: "全部语言", value: "" }, { label: "全部语言", value: "" },
{ label: "Python", value: "Python3" }, { label: "Python", value: "Python" },
{ label: "C语言", value: "C" }, { label: "C语言", value: "C" },
{ label: "C++", value: "C++" }, { label: "C++", value: "C++" },
] ]
+1 -1
View File
@@ -81,7 +81,7 @@ async function init() {
firstSubmissionAt.value = parseTime(metricsRes.first) firstSubmissionAt.value = parseTime(metricsRes.first)
latestSubmissionAt.value = parseTime(metricsRes.latest) latestSubmissionAt.value = parseTime(metricsRes.latest)
toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now) toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
learnDuration.value = durationToDays(metricsRes.first, metricsRes.latest) learnDuration.value = `${metricsRes.activeDays}`
} }
} finally { } finally {
toggle(false) toggle(false)
@@ -4,6 +4,7 @@ import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql" import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language" import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror" import { Codemirror } from "vue-codemirror"
import type { Extension } from "@codemirror/state"
import { import {
autocompletion, autocompletion,
closeBrackets, closeBrackets,
@@ -21,14 +22,17 @@ interface Props {
height?: string height?: string
readonly?: boolean readonly?: boolean
placeholder?: string placeholder?: string
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
extraExtensions?: Extension[]
} }
const { const {
language = "Python3", language = "Python",
fontSize = 20, fontSize = 20,
height = "100%", height = "100%",
readonly = false, readonly = false,
placeholder = "", placeholder = "",
extraExtensions = [],
} = defineProps<Props>() } = defineProps<Props>()
const code = defineModel<string>("value") const code = defineModel<string>("value")
@@ -37,7 +41,7 @@ const isDark = useDark()
const langExtension = computed(() => { const langExtension = computed(() => {
if (language === "SQL") if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true }) return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp() return language === "Python" ? python() : cpp()
}) })
const extensions = computed(() => [ const extensions = computed(() => [
@@ -49,6 +53,7 @@ const extensions = computed(() => [
override: [enhanceCompletion(language), completeAnyWord], override: [enhanceCompletion(language), completeAnyWord],
}), }),
isDark.value ? oneDark : smoothy, isDark.value ? oneDark : smoothy,
...extraExtensions,
]) ])
</script> </script>
@@ -7,6 +7,7 @@ import {
completeAnyWord, completeAnyWord,
} from "@codemirror/autocomplete" } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view" import type { EditorView } from "@codemirror/view"
import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark" import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy" import { smoothy } from "../themes/smoothy"
@@ -26,6 +27,8 @@ interface Props {
height?: string height?: string
readonly?: boolean readonly?: boolean
placeholder?: string placeholder?: string
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
extraExtensions?: Extension[]
/** /**
* 当前这个编辑器属于哪道题题目的展示 ID * 当前这个编辑器属于哪道题题目的展示 ID
* *
@@ -38,11 +41,12 @@ interface Props {
} }
const { const {
language = "Python3", language = "Python",
fontSize = 20, fontSize = 20,
height = "100%", height = "100%",
readonly = false, readonly = false,
placeholder = "", placeholder = "",
extraExtensions = [],
problemId = "", problemId = "",
} = defineProps<Props>() } = defineProps<Props>()
const code = defineModel<string>("value") const code = defineModel<string>("value")
@@ -59,6 +63,7 @@ const extensions = computed(() => [
override: [enhanceCompletion(language), completeAnyWord], override: [enhanceCompletion(language), completeAnyWord],
}), }),
getInitialExtension(), getInitialExtension(),
...extraExtensions,
]) ])
interface EditorReadyPayload { interface EditorReadyPayload {
@@ -19,7 +19,8 @@ export function useConfigUpdate() {
const handleConfigUpdate = (data: ConfigUpdate) => { const handleConfigUpdate = (data: ConfigUpdate) => {
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段 // 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return if (!(data.key in configStore.config)) return
;(configStore.config as any)[data.key] = data.value ;(configStore.config as unknown as Record<string, unknown>)[data.key] =
data.value
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份 // getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
if (data.key === "websiteName") document.title = data.value if (data.key === "websiteName") document.title = data.value
} }
+12 -10
View File
@@ -42,11 +42,13 @@ export function usePagination<T extends Record<string, any>>(
limit: parseInt(<string>route.query.limit) || defaultLimit, limit: parseInt(<string>route.query.limit) || defaultLimit,
...initialQuery, ...initialQuery,
}) as unknown as T & PaginationQuery }) as unknown as T & PaginationQuery
// 键是运行时按 initialQuery 枚举出来的,静态类型写不出来;写入统一走这一个口子
const writable = query as Record<string, unknown>
// 同步 URL 查询参数到本地状态 // 同步 URL 查询参数到本地状态
function syncFromRoute() { function syncFromRoute() {
;(query as any).page = parseInt(<string>route.query.page) || defaultPage writable.page = parseInt(<string>route.query.page) || defaultPage
;(query as any).limit = parseInt(<string>route.query.limit) || defaultLimit writable.limit = parseInt(<string>route.query.limit) || defaultLimit
// 同步其他查询参数 // 同步其他查询参数
Object.keys(initialQuery).forEach((key) => { Object.keys(initialQuery).forEach((key) => {
@@ -54,11 +56,11 @@ export function usePagination<T extends Record<string, any>>(
if (value !== undefined) { if (value !== undefined) {
// 处理不同类型的参数 // 处理不同类型的参数
if (typeof initialQuery[key] === "boolean") { if (typeof initialQuery[key] === "boolean") {
;(query as any)[key] = value === "1" || value === "true" writable[key] = value === "1" || value === "true"
} else if (typeof initialQuery[key] === "number") { } else if (typeof initialQuery[key] === "number") {
;(query as any)[key] = parseInt(<string>value) || initialQuery[key] writable[key] = parseInt(<string>value) || initialQuery[key]
} else { } else {
;(query as any)[key] = <string>value || initialQuery[key] writable[key] = <string>value || initialQuery[key]
} }
} }
}) })
@@ -75,7 +77,7 @@ export function usePagination<T extends Record<string, any>>(
// 重置页码到第一页 // 重置页码到第一页
function resetPage() { function resetPage() {
;(query as any).page = defaultPage writable.page = defaultPage
} }
// 清空所有查询条件(除了分页参数) // 清空所有查询条件(除了分页参数)
@@ -83,13 +85,13 @@ export function usePagination<T extends Record<string, any>>(
Object.keys(initialQuery).forEach((key) => { Object.keys(initialQuery).forEach((key) => {
const initialValue = initialQuery[key] const initialValue = initialQuery[key]
if (typeof initialValue === "string") { if (typeof initialValue === "string") {
;(query as any)[key] = "" writable[key] = ""
} else if (typeof initialValue === "boolean") { } else if (typeof initialValue === "boolean") {
;(query as any)[key] = false writable[key] = false
} else if (typeof initialValue === "number") { } else if (typeof initialValue === "number") {
;(query as any)[key] = 0 writable[key] = 0
} else { } else {
;(query as any)[key] = initialValue writable[key] = initialValue
} }
}) })
resetPage() resetPage()
+1 -1
View File
@@ -13,5 +13,5 @@ import type { LANGUAGE } from "utils/types"
export function languageExtension(language: LANGUAGE): Extension { export function languageExtension(language: LANGUAGE): Extension {
if (language === "SQL") if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true }) return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp() return language === "Python" ? python() : cpp()
} }
+18 -10
View File
@@ -1,3 +1,4 @@
import type { JudgeStatusValue } from "@oj2/contract"
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types" import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。 // 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。
@@ -21,6 +22,18 @@ export enum SubmissionStatus {
ast_check_failed = 10, ast_check_failed = 10,
} }
// 编译期对齐契约:契约加/改一个码而这里没跟,下面两行会当场编译不过。
type SyncedWithContract =
Exclude<
`${SubmissionStatus}`,
`${SubmissionStatus.submitting}`
> extends `${JudgeStatusValue}`
? `${JudgeStatusValue}` extends `${Exclude<SubmissionStatus, SubmissionStatus.submitting>}`
? true
: never
: never
export const _submissionStatusSynced: SyncedWithContract = true
export enum ContestStatus { export enum ContestStatus {
initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位 initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
not_started = "1", not_started = "1",
@@ -164,8 +177,7 @@ export const SOURCES = {
C: cSource, C: cSource,
"C++": cppSource, "C++": cppSource,
Java: javaSource, Java: javaSource,
Python3: pythonSource, Python: pythonSource,
Python2: "",
JavaScript: "", JavaScript: "",
Golang: "", Golang: "",
Flowchart: "", Flowchart: "",
@@ -176,8 +188,7 @@ export const LANGUAGE_FORMAT_VALUE = {
C: "c", C: "c",
"C++": "cpp", "C++": "cpp",
Java: "java", Java: "java",
Python2: "python", Python: "python",
Python3: "python",
JavaScript: "javascript", JavaScript: "javascript",
Golang: "go", Golang: "go",
Flowchart: "flowchart", Flowchart: "flowchart",
@@ -189,8 +200,7 @@ export const LANGUAGE_SHOW_VALUE = {
C: "C语言", C: "C语言",
"C++": "C++", "C++": "C++",
Java: "Java", Java: "Java",
Python2: "Python", Python: "Python",
Python3: "Python",
JavaScript: "JS", JavaScript: "JS",
Golang: "Go", Golang: "Go",
SQL: "SQL", SQL: "SQL",
@@ -198,8 +208,7 @@ export const LANGUAGE_SHOW_VALUE = {
export const ICON_SET = { export const ICON_SET = {
Flowchart: "vscode-icons:file-type-drawio", Flowchart: "vscode-icons:file-type-drawio",
Python2: "devicon:python", Python: "devicon:python",
Python3: "devicon:python",
C: "devicon:c", C: "devicon:c",
"C++": "devicon:cplusplus", "C++": "devicon:cplusplus",
Java: "devicon:java", Java: "devicon:java",
@@ -237,8 +246,7 @@ const blankTemplate = `//PREPEND BEGIN
export const CODE_TEMPLATES = { export const CODE_TEMPLATES = {
C: cTemplate, C: cTemplate,
"C++": cppTemplate, "C++": cppTemplate,
Python2: blankTemplate, Python: blankTemplate,
Python3: blankTemplate,
Java: blankTemplate, Java: blankTemplate,
JavaScript: blankTemplate, JavaScript: blankTemplate,
Golang: blankTemplate, Golang: blankTemplate,
+1 -2
View File
@@ -12,8 +12,7 @@ const JUDGE0_LANGUAGE_ID: Partial<Record<LANGUAGE, number>> = {
Java: 62, Java: 62,
Golang: 60, Golang: 60,
JavaScript: 63, JavaScript: 63,
Python2: 70, Python: 71,
Python3: 71,
} }
export async function createTestSubmission(code: Code, input: string) { export async function createTestSubmission(code: Code, input: string) {
+4
View File
@@ -19,6 +19,10 @@ JUDGE_CONCURRENCY=2
# DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。 # DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。
AI_KEY= AI_KEY=
# AI 提示走两段式(先诊断、再生成)。填 1 打开,留空为关。
# 服务器和机房共用一个库、各有各的 .env —— 两边要一起开关,不然 ai_hint 里两种口径的数据混在一起。
AI_HINT_DIAGNOSE=
# --- 数据在哪 --- # --- 数据在哪 ---
# #
# 这三个变量决定新栈是「自带 postgres/redis」还是「接着用旧栈的」。 # 这三个变量决定新栈是「自带 postgres/redis」还是「接着用旧栈的」。
+6 -1
View File
@@ -72,7 +72,11 @@ services:
retries: 10 retries: 10
oj-judge: oj-judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 # 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
# 上游停更在 2024-04registry 上的 1.6.1 == latest,没有新版可拉。
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
image: oj2-judge-2
container_name: oj-judge container_name: oj-judge
restart: always restart: always
read_only: true read_only: true
@@ -135,6 +139,7 @@ services:
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2} JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
AI_KEY: ${AI_KEY:-} AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 走 NPM 终止 TLS,浏览器侧是 httpsCookie 必须带 Secure # 走 NPM 终止 TLS,浏览器侧是 httpsCookie 必须带 Secure
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
healthcheck: healthcheck:
+5 -1
View File
@@ -57,7 +57,11 @@ services:
retries: 10 retries: 10
judge: judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 # 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
# 上游停更在 2024-04registry 上的 1.6.1 == latest,没有新版可拉。
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
image: oj2-judge-2
container_name: oj2-judge container_name: oj2-judge
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
+6 -1
View File
@@ -29,7 +29,11 @@ services:
retries: 10 retries: 10
oj-judge: oj-judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 # 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
# 上游停更在 2024-04registry 上的 1.6.1 == latest,没有新版可拉。
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
image: oj2-judge-2
container_name: oj-judge container_name: oj-judge
restart: always restart: always
read_only: true read_only: true
@@ -79,6 +83,7 @@ services:
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4} JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4}
AI_KEY: ${AI_KEY:-} AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传, # 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传,
# 学生会「登录成功但立刻又是未登录」。这里必须是 false。 # 学生会「登录成功但立刻又是未登录」。这里必须是 false。
COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SECURE: ${COOKIE_SECURE:-false}
+16 -1
View File
@@ -152,7 +152,22 @@ judge_dir=$(grep -E '^JUDGE_STATE_DIR=' docker/.env | tail -1 | cut -d= -f2- ||
[ -n "$judge_dir" ] || die "JUDGE_STATE_DIR 没设 —— 试跑期间新旧两个判题机会共用运行目录" [ -n "$judge_dir" ] || die "JUDGE_STATE_DIR 没设 —— 试跑期间新旧两个判题机会共用运行目录"
ok "判题机运行目录 $judge_dir" ok "判题机运行目录 $judge_dir"
# ④ 外接形态才需要预检:库不归本栈管,得确认它已经活着。 # ④ 判题镜像是自建的(上游 JudgeServer 停更,官方镜像的编译器停在 gcc-13),
# registry 上没有这个 tag。忘了 docker load 的话,要到「起栈」那步 compose 去 pull
# 才失败 —— 不如在这里就把该跑的三条命令说清楚。
# 回滚到官方镜像时这段自动跳过:那个 tag 是 pull 得到的。
judge_image=$(grep -m1 -E '^[[:space:]]*image: oj2-judge' <<<"$cfg" | awk '{print $2}' || true)
if [ -n "$judge_image" ]; then
docker image inspect "$judge_image" >/dev/null 2>&1 \
|| die "判题镜像 $judge_image 不在这台机器上,而且 registry 上也没有(它是自建的)。
本机:docker/judge/build.sh --save
scp dist/${judge_image/:/-}.tar root@这台机器:/root/OJDeploy/
这里:docker load -i /root/OJDeploy/${judge_image/:/-}.tar
构建和回滚见 docker/judge/README.md。"
ok "判题镜像 $judge_image 在本机"
fi
# ⑤ 外接形态才需要预检:库不归本栈管,得确认它已经活着。
# 自带形态下这两个容器就是本栈自己起的,起栈那步会拉起来,这里没什么可查。 # 自带形态下这两个容器就是本栈自己起的,起栈那步会拉起来,这里没什么可查。
if [ "$LOCAL_DATA" -eq 0 ]; then if [ "$LOCAL_DATA" -eq 0 ]; then
for c in oj-postgres oj-redis; do for c in oj-postgres oj-redis; do
+148
View File
@@ -0,0 +1,148 @@
# 判题沙箱镜像。**这是 QingdaoU/JudgeServer 官方 Dockerfile 的分叉**
# 只改工具链版本,server/ 和 Judger/ 的代码一行都没动(构建时从上游仓库
# 的固定 commit 拉,见 build.sh)。
#
# 为什么要分叉:上游停更在 2024-04-05b28aa56,也就是 1.6.1 这个 tag),
# registry 上的 `latest` 和 `1.6.1` 是同一份镜像,没有新版可升。想要新编译器
# 只能自己构建。
#
# 相对上游的全部改动:
# gcc/g++ 13 → 14 (trixie 默认)
# Python 3.12 → 3.13 (trixie 默认)
# Go / JDK / Node → **整套删掉**(见下)
#
# 只留 C / C++ / Python3 的工具链:前端的语言复选框从来只给这三种加 SQL,
# 生产库 12 万条提交里 Java/Golang/JavaScript 一共 62 条、全是很早以前的。
# 删掉 golang-1.24-go、temurin-25-jdk、nodejs 之后镜像从 1.16GB 掉到 ~500MB
# 构建也少了 NodeSource 那个 39MB 的 deb(它没有可用的国内镜像,最慢的一块)。
# 想恢复某种语言:这里加回包 + alternatives,同时改 apps/api/src/judge/languages.ts。
#
# ⚠️ 语言的编译/运行命令**不在这个文件里**,在 apps/api/src/judge/languages.ts。
# 升 gcc 大版本要同步看那边的开关(gcc-14 把 implicit-function-declaration
# 等提成了 errorlanguages.ts 里有三个 -Wno-error 把它压回去)。
# 镜像源。默认走国内镜像 —— 官方源在这边实测 197 KB/s,清华 3.9 MB/s
# 整个构建从 12 分钟掉到 2 分钟出头。出国内网络环境用 build.sh --no-mirror 关掉。
#
# ⚠️ debian 这两个只能用 **http**:改 sources 这一步发生在装 ca-certificates 之前,
# base 镜像里没有 CA 根证书,https 一律 `certificate verify failed`。
# PyPI 那条是 pip 自己请求的,pip 内置 certifi,不依赖系统 CA。
ARG APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian
ARG APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
FROM debian:trixie-slim AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ARG APT_MIRROR
ARG APT_SECURITY_MIRROR
ARG PIP_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,id=apt-cahce-1-$TARGETARCH$TARGETVARIANT-builder,sharing=locked \
--mount=type=cache,target=/var/lib/apt,id=apt-cahce-2-$TARGETARCH$TARGETVARIANT-builder,sharing=locked \
<<EOS
set -ex
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/no-recommends
echo 'APT::AutoRemove::RecommendsImportant "0";' >> /etc/apt/apt.conf.d/no-recommends
# 换镜像源。debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
# 反过来的话第二条 sed 会把它改成 <镜像>/debian-security,路径不存在。
sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|; s|http://deb.debian.org/debian|$APT_MIRROR|" /etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y libtool make cmake libseccomp-dev gcc python3 python3-venv
EOS
COPY Judger/ /app/
RUN <<EOS
set -ex
mkdir /app/build
cmake -S . -B build
cmake --build build --parallel $(nproc)
EOS
RUN <<EOS
set -ex
cd bindings/Python
python3 -m venv .venv
.venv/bin/pip3 install -i "$PIP_INDEX_URL" build
.venv/bin/python3 -m build -w
EOS
FROM debian:trixie-slim
ARG TARGETARCH
ARG TARGETVARIANT
ARG APT_MIRROR
ARG APT_SECURITY_MIRROR
ARG PIP_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,id=apt-cahce-1-$TARGETARCH$TARGETVARIANT-final,sharing=locked \
--mount=type=cache,target=/var/lib/apt,id=apt-cahce-2-$TARGETARCH$TARGETVARIANT-final,sharing=locked \
<<EOS
set -ex
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/no-recommends
echo 'APT::AutoRemove::RecommendsImportant "0";' >> /etc/apt/apt.conf.d/no-recommends
needed="python3.13-minimal \
python3.13-venv \
libpython3.13-stdlib \
libpython3.13-dev \
gcc-14 \
g++-14 \
strace"
savedAptMark="$(apt-mark showmanual) $needed"
# 换镜像源。debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
# 反过来的话第二条 sed 会把它改成 <镜像>/debian-security,路径不存在。
sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|; s|http://deb.debian.org/debian|$APT_MIRROR|" /etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y $needed
# languages.ts 里的编译/运行命令写的是绝对路径(/usr/bin/gcc、/usr/bin/python3 …),
# 全靠下面这几条 alternatives 挂出来。换包名必须同步改,漏一条的表现是 CE 而不是报错。
update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 14
update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-14 14
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.13 13
apt-mark auto '.*' > /dev/null
apt-mark manual $savedAptMark
apt-get purge -y --auto-remove
EOS
COPY --from=builder --chmod=755 --link /app/output/libjudger.so /usr/lib/judger/libjudger.so
COPY --from=builder /app/bindings/Python/dist/ /app/
RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cahce-$TARGETARCH$TARGETVARIANT-final \
<<EOS
set -ex
python3 -m venv .venv
CC=gcc .venv/bin/pip3 install -i "$PIP_INDEX_URL" --compile --no-cache-dir flask gunicorn idna psutil requests
.venv/bin/pip3 install *.whl
EOS
COPY server/ /app/
RUN <<EOS
set -ex
chmod -R u=rwX,go=rX /app/
chmod +x /app/entrypoint.sh
gcc -shared -fPIC -o unbuffer.so unbuffer.c
useradd -u 901 -r -s /sbin/nologin -M compiler
useradd -u 902 -r -s /sbin/nologin -M code
useradd -u 903 -r -s /sbin/nologin -M -G code spj
mkdir -p /usr/lib/judger
EOS
# 构建期自检:这五行任何一个不是预期版本,说明上面的包名或 alternatives 配错了。
RUN <<EOS
set -ex
gcc --version
g++ --version
python3 --version
EOS
HEALTHCHECK --interval=5s CMD [ "/app/.venv/bin/python3", "/app/service.py" ]
EXPOSE 8080
ENTRYPOINT [ "/app/entrypoint.sh" ]
+104
View File
@@ -0,0 +1,104 @@
# 判题沙箱镜像
判题机跑的是 [QingdaoU/JudgeServer](https://github.com/QingdaoU/JudgeServer)。
**上游已经停更**:master 最后一次提交是 2024-04-05`b28aa56`,即 tag `v1.6.1`),
registry 上的 `oj-image/judge:latest``:1.6.1` 是同一份镜像(config digest 都是
`221bf4c0e730`)。所以想换新编译器,只能自己构建。
这里放的是**只改工具链的分叉**`server/`Flask + `_judger` 那套判题逻辑)和
`Judger/`libjudger.so 沙箱内核)一行都没动,构建时从上游那个固定 commit 拉。
## 工具链
2026-09 从上游镜像升级,同时把用不上的三种语言整套砍掉:
| | 上游 1.6.1 | 现在 |
|---|---|---|
| gcc / g++ | 13 | **14.2**trixie 默认) |
| Python | 3.12 | **3.13.5**trixie 默认) |
| Go | 1.22 | **删掉** |
| Node | 20.x | **删掉** |
| JDK | temurin-21 | **删掉** |
| base | debian:trixie-slim2024 年的) | debian:trixie-slim(当前) |
| 镜像体积 | 1.1 GB | **433 MB** |
砍语言的依据:前端的题目语言复选框从来只给 Python / C / C++ / SQL
生产库 12 万条提交里 Java 44 条、Golang 15 条、JavaScript 3 条,全是很早以前的。
契约 `judgeLanguageSchema` 里那几个键留着(渲染历史提交要用),只是判题机不再认。
要恢复某种语言:Dockerfile 里加回包和 `update-alternatives`,同时改
`apps/api/src/judge/languages.ts`,两边缺一个都是静默失败。
## 镜像源
默认走清华源(`Dockerfile` 顶部三个 ARG)。官方源在这边实测 **197 KB/s**,清华
**3.9 MB/s**,整个构建从 12 分钟掉到 1 分钟以内。
`build.sh --no-mirror` 换回官方源。debian 那两个只能用 http —— 改 sources 发生在
装 ca-certificates 之前,base 镜像里没有 CA 根证书,https 一律
`certificate verify failed`(这个坑踩过)。
## 构建与分发
```bash
docker/judge/build.sh --save # 本机构建 + 导出 dist/oj2-judge-2.tar
scp dist/oj2-judge-2.tar root@服务器:/root/OJDeploy/
ssh root@服务器 'docker load -i /root/OJDeploy/oj2-judge-2.tar'
# 机房那台同样来一遍 —— 两个站点各有各的判题沙箱
```
之后正常 `docker/deploy.sh` 即可。
⚠️ **这个名字不在任何 registry 上。** 服务器上忘了 `docker load`compose 会去
pull 然后报找不到镜像(好在是响亮地失败,不是静默降级)。
⚠️ **改工具链就把末尾的序号 +1**(下一版叫 `oj2-judge-3``build.sh` 里的 `IMAGE`
compose 里三处,一起改)。`docker compose up -d` 不带 `--pull`,名字没变会静默用机器上
的旧镜像。官方镜像算第 1 版,所以我们自己重编的从 `-2` 起。
回滚:把三个 compose 的 image 改回
`registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1`,重新 `up -d`。别在服务器上
`docker image prune` 把那份旧镜像清掉。
## 和 `languages.ts` 的关系
**编译和运行命令不在镜像里**,在 `apps/api/src/judge/languages.ts`。镜像只负责把
`/usr/bin/gcc``/usr/bin/python3``/usr/bin/go``/usr/bin/node``/usr/bin/java`
这些绝对路径挂到正确的版本上(Dockerfile 末尾的 `update-alternatives`)。
gcc-14 把隐式函数声明、int↔指针互赋、不兼容指针类型从 warning 提成了 error`-w`
压不住。`languages.ts` 里的 `cLooseErrors` 三个 `-Wno-error=` 就是为此加的 ——
实测 1951 份历史 C 提交和 20 篇 C 教程的 93 个代码块,加了之后与 gcc-13 逐个文件
结果完全一致;不加的话有一批会从能过变成 CE。
## 这次升级是怎么验的
不写测试,全是实跑。除了 `smoke.ts` 的 13 条,还拿**生产库备份里的真实代码**逐个
文件对比了新旧镜像的编译结果(脚本是一次性的,结论记在这里):
| 语料 | 份数 | 老镜像 (gcc-13 / py3.12) | 新镜像 (gcc-14 / py3.13) | 差异 |
|---|---|---|---|---|
| 历史 C 提交(共 19262,随机抽样) | 1951 | 1725 过 / 226 CE | 一模一样 | **0** |
| 历史 C++ 提交(全量) | 882 | 603 过 / 279 CE | 一模一样 | **0** |
| 历史 Python 提交(共 104527,随机抽样) | 2000 | 1833 过 / 167 CE | 一模一样 | **0** |
| 20 篇 C 教程里含 `int main` 的代码块 | 93 | 93 全过 | 一模一样 | **0** |
**不加 `cLooseErrors` 那三个开关的话,1951 份 C 提交里有 26 份会从「能过」变成 CE**
(按比例算全库约 260 条),全是忘了 `#include <string.h>` 之类的隐式函数声明。
教程那 93 块本身写得规范,加不加都全过。
## 换镜像后怎么验
```bash
bun docker/judge/smoke.ts # 六种语言 + 六种状态码 + gcc 宽松度
```
它直接打判题机的 `/judge`,不需要起后端、不需要库里有题。用的 `languageConfigs`
就是线上那份,所以配置和镜像对不上会当场暴露。
**Go 那条坑记一下**(升级时发现的,升级之前就有):`GOCACHE` 指向容器的 tmpfs
`/tmp`,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒的编译
预算 —— 重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。Go 现在整个
删掉了,但**以后加回任何需要编译缓存的语言,记得把编译预算放宽**。
判题机装好之后,后台「判题机列表」应该能看到它上线(心跳走
`POST /api/judge-server/heartbeat`5 秒一次)。
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# 构建判题沙箱镜像(本机构建,产物用 docker save 传到服务器和机房)。
#
# docker/judge/build.sh # 构建并打 tag
# docker/judge/build.sh --save # 顺便导出 tar(给 scp 用)
# docker/judge/build.sh --no-cache # 不吃构建缓存
# docker/judge/build.sh --no-mirror # 不走国内镜像源(默认走)
#
# 上游 JudgeServer 停更在 2024-04-05registry 上的 1.6.1 == latest,没有新版
# 可拉。这个脚本从上游那个固定 commit 拉源码(server/ 和 Judger/ 一行不改),
# 只把 Dockerfile 换成 docker/judge/Dockerfile —— 新工具链的全部改动都在那里。
#
# 上传和切换见 docker/judge/README.md。
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# 上游 master HEAD= tag v1.6.1+judgeserver.1.6.1)。Judger 子模块的版本由这个
# commit 自己钉住(d19a6dc),不用在这里再写一遍。
UPSTREAM_REPO=https://github.com/QingdaoU/JudgeServer.git
UPSTREAM_COMMIT=b28aa56d60fed7358a29d9bdeb9d86fcc06e41a7
# 我们自己重编的第几版判题镜像(官方那个算第 1 版,这是第 2 版)。
# **改工具链就把末尾的序号 +1**(下一版叫 oj2-judge-3),别在同一个名字上重建 ——
# compose 的 `up -d` 不带 --pull,名字没变就会静默用机器上的旧镜像。
IMAGE=oj2-judge-2
SAVE=0
BUILD_ARGS=()
for arg in "$@"; do
case "$arg" in
--save) SAVE=1 ;;
--no-cache) BUILD_ARGS+=(--no-cache) ;;
# 默认用国内镜像源(Dockerfile 顶部的四个 ARG)。在能直连的网络里用这个关掉,
# 换回 deb.debian.org / pypi.org。
--no-mirror)
BUILD_ARGS+=(
--build-arg APT_MIRROR=http://deb.debian.org/debian
--build-arg APT_SECURITY_MIRROR=http://deb.debian.org/debian-security
--build-arg PIP_INDEX_URL=https://pypi.org/simple
) ;;
*) echo "未知参数:$arg(可用:--save、--no-cache、--no-mirror" >&2; exit 2 ;;
esac
done
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
command -v docker >/dev/null || die "没装 docker"
[ -f docker/judge/Dockerfile ] || die "docker/judge/Dockerfile 不见了,当前目录:$PWD"
src=$(mktemp -d)
trap 'rm -rf "$src"' EXIT
say "拉上游源码 $UPSTREAM_COMMIT"
git -c advice.detachedHead=false clone --quiet "$UPSTREAM_REPO" "$src"
git -C "$src" -c advice.detachedHead=false checkout --quiet "$UPSTREAM_COMMIT"
git -C "$src" submodule update --quiet --init --recursive
ok "server/ 和 Judger/ 就位"
say "构建 $IMAGE"
# 上下文是上游源码,Dockerfile 用我们自己的那份。
docker build "${BUILD_ARGS[@]}" -f docker/judge/Dockerfile -t "$IMAGE" "$src"
ok "$IMAGE"
say "镜像里的工具链"
docker run --rm --entrypoint sh "$IMAGE" -c '
printf "gcc %s\n" "$(gcc -dumpfullversion)"
printf "g++ %s\n" "$(g++ -dumpfullversion)"
printf "python3 %s\n" "$(python3 -V | cut -d" " -f2)"
'
if [ "$SAVE" = 1 ]; then
out="dist/${IMAGE/:/-}.tar"
mkdir -p dist
say "导出 $out"
docker save "$IMAGE" -o "$out"
ok "$(du -h "$out" | cut -f1) → scp 到服务器后 docker load -i"
fi
+266
View File
@@ -0,0 +1,266 @@
/**
* ****
*
* bun docker/judge/smoke.ts # .env JUDGE_SERVER_URL
* JUDGE_SERVER_URL=http://localhost:8082 bun docker/judge/smoke.ts
*
* /judge api / worker /
* `test_case`
*
*
* 1. languageConfigs 线
* 2. **** packages/contract/src/judge-status.ts
* 3. gcc #include gcc-14 CE
*/
import { createHash } from "node:crypto"
import { JudgeStatus } from "@oj2/contract"
import { languageConfigs } from "../../apps/api/src/judge/languages"
const url = process.env.JUDGE_SERVER_URL ?? "http://localhost:8081"
const rawToken = process.env.JUDGE_SERVER_TOKEN
if (!rawToken) {
console.error("JUDGE_SERVER_TOKEN 没设 —— 在 OJ2 根目录跑,bun 会自己读 .env")
process.exit(2)
}
const token = createHash("sha256").update(rawToken).digest("hex")
interface Case {
language: string
name: string
code: string
expect: number
/** 默认 3000ms / 128MB,跑得慢或要撑爆内存的用例自己改 */
cpu?: number
memory?: number
}
const sumTestCase = [{ input: "1 2\n", output: "3\n" }]
const cases: Case[] = [
// ---------------------------------------------------------------- C
{
language: "C",
name: "C 正常通过",
expect: JudgeStatus.ACCEPTED,
code: `#include <stdio.h>
int main(void) {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\\n", a + b);
return 0;
}`,
},
{
// 这条是升 gcc 的主要风险点:gcc-14 起 implicit-function-declaration 是
// errorlanguages.ts 里的三个 -Wno-error 就是为它加的。这条挂了说明那些
// 开关没生效 —— 后果是一批历史题解和 20 篇 C 教程的示例突然全 CE。
language: "C",
name: "C 忘了 #include 仍能过(gcc 宽松度)",
expect: JudgeStatus.ACCEPTED,
code: `int main(void) {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\\n", a + b);
return 0;
}`,
},
{
language: "C",
name: "C 答案错误",
expect: JudgeStatus.WRONG_ANSWER,
code: `#include <stdio.h>
int main(void) {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\\n", a + b + 1);
return 0;
}`,
},
{
language: "C",
name: "C 编译错误",
expect: JudgeStatus.COMPILE_ERROR,
code: `int main(void) { return }`,
},
{
language: "C",
name: "C 运行超时",
expect: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
cpu: 1000,
code: `int main(void) {
volatile long x = 0;
while (1) x++;
return 0;
}`,
},
{
// RLIMIT_AS 是 max_memory 的两倍,所以 malloc 会先成功一阵子再失败,
// 退出时 ru_maxrss 已经超过 max_memory → judger 判 MLE 而不是 RE。
language: "C",
name: "C 内存超限",
expect: JudgeStatus.MEMORY_LIMIT_EXCEEDED,
memory: 64 * 1024 * 1024,
code: `#include <stdlib.h>
#include <string.h>
int main(void) {
for (;;) {
char *p = malloc(8 * 1024 * 1024);
if (!p) return 1;
memset(p, 1, 8 * 1024 * 1024);
}
}`,
},
{
language: "C",
name: "C 运行时错误",
expect: JudgeStatus.RUNTIME_ERROR,
code: `int main(void) {
int *p = 0;
*p = 1;
return 0;
}`,
},
// ---------------------------------------------------------------- Python
{
language: "Python",
name: "Python 正常通过",
expect: JudgeStatus.ACCEPTED,
code: `a, b = map(int, input().split())
print(a + b)`,
},
{
language: "Python",
name: "Python 编译错误",
expect: JudgeStatus.COMPILE_ERROR,
code: `def (:`,
},
{
language: "Python",
name: "Python 运行超时",
expect: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
cpu: 1000,
code: `while True:
pass`,
},
{
language: "Python",
name: "Python 运行时错误",
expect: JudgeStatus.RUNTIME_ERROR,
code: `print(1 / 0)`,
},
// ---------------------------------------------------------------- C++
{
language: "C++",
name: "C++ 正常通过",
expect: JudgeStatus.ACCEPTED,
code: `#include <iostream>
int main() {
int a, b;
std::cin >> a >> b;
std::cout << a + b << std::endl;
return 0;
}`,
},
{
language: "C++",
name: "C++ 编译错误",
expect: JudgeStatus.COMPILE_ERROR,
code: `int main() { return }`,
},
]
const names: Record<number, string> = {
[JudgeStatus.COMPILE_ERROR]: "CE",
[JudgeStatus.WRONG_ANSWER]: "WA",
[JudgeStatus.ACCEPTED]: "AC",
[JudgeStatus.CPU_TIME_LIMIT_EXCEEDED]: "TLE(cpu)",
[JudgeStatus.REAL_TIME_LIMIT_EXCEEDED]: "TLE(real)",
[JudgeStatus.MEMORY_LIMIT_EXCEEDED]: "MLE",
[JudgeStatus.RUNTIME_ERROR]: "RE",
[JudgeStatus.SYSTEM_ERROR]: "SE",
}
const label = (code: number) => `${names[code] ?? "?"}(${code})`
async function runCase(item: Case) {
const response = await fetch(new URL("/judge", url), {
method: "POST",
headers: {
"content-type": "application/json",
"X-Judge-Server-Token": token,
},
body: JSON.stringify({
language_config: languageConfigs[item.language],
src: item.code,
max_cpu_time: item.cpu ?? 3000,
max_memory: item.memory ?? 128 * 1024 * 1024,
test_case: sumTestCase,
output: false,
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
}),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = (await response.json()) as {
err: string | null
data: unknown
}
// 编译失败走 err 通道,不会有逐测试点的结果
if (body.err === "CompileError") return { result: JudgeStatus.COMPILE_ERROR }
if (body.err) throw new Error(`${body.err}: ${JSON.stringify(body.data)}`)
const results = body.data as { result: number; cpu_time: number }[]
// 多个测试点取最坏的那个,和 run.ts 的口径一致
const failed = results.find((r) => r.result !== JudgeStatus.ACCEPTED)
return failed ?? results[0]!
}
/** 判题机刚重建时 gunicorn 还没起来,先等它 —— 否则整屏都是连接被关。 */
async function waitReady() {
for (let i = 0; i < 60; i++) {
try {
const response = await fetch(new URL("/ping", url), {
method: "POST",
headers: { "X-Judge-Server-Token": token },
})
if (response.ok) return
} catch {
// 还没起来,接着等
}
await Bun.sleep(500)
}
console.error(`连不上判题机 ${url}(等了 30 秒)`)
process.exit(2)
}
await waitReady()
let failures = 0
console.log(`判题机 ${url}\n`)
for (const item of cases) {
try {
const got = await runCase(item)
const pass = got.result === item.expect
if (!pass) failures++
const time = "cpu_time" in got ? ` ${got.cpu_time}ms` : ""
console.log(
`${pass ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"} ${item.name.padEnd(32)}` +
` 期望 ${label(item.expect).padEnd(10)} 实得 ${label(got.result)}${time}`,
)
} catch (error) {
failures++
console.log(`\x1b[31m✗\x1b[0m ${item.name.padEnd(32)} ${error}`)
}
}
console.log(
failures === 0
? `\n\x1b[32m全部 ${cases.length} 条通过\x1b[0m`
: `\n\x1b[31m${failures} / ${cases.length} 条不对\x1b[0m`,
)
process.exit(failures === 0 ? 0 : 1)
+1 -1
View File
@@ -27,7 +27,7 @@ bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里
## 只有三种语言真的会跑 ## 只有三种语言真的会跑
判题机只认 `AST_SUPPORTED_LANGUAGES`C / C++ / Python3)。别的语言配了规则一条都不会跑, 判题机只认 `AST_SUPPORTED_LANGUAGES`C / C++ / Python)。别的语言配了规则一条都不会跑,
所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」—— 所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」——
**看得见却不检查**比没有更糟。 **看得见却不检查**比没有更糟。
+17
View File
@@ -64,6 +64,23 @@ docker/deploy.sh --no-build # 只改了 env / compose 时跳过构建
有没有生效、库指向和形态是否自洽、判题机运行目录有没有和旧栈分开、外接的 有没有生效、库指向和形态是否自洽、判题机运行目录有没有和旧栈分开、外接的
postgres / redis 是否活着。起完再跑四条冒烟,**题目数是 0 也中止** —— 那意味着连错库了。 postgres / redis 是否活着。起完再跑四条冒烟,**题目数是 0 也中止** —— 那意味着连错库了。
### 判题镜像是自建的,不在 registry 上
`compose.*.yml` 里的 `oj2-judge-2` 是本机构建的(上游 JudgeServer 停更在
2024-04,官方镜像的编译器停在 gcc-13)。**新机器或换镜像之后,先把镜像 load 进去
再部署**
```bash
# 本机
docker/judge/build.sh --save
scp dist/oj2-judge-2.tar root@服务器:/root/OJDeploy/
# 服务器 / 机房各来一次(两个站点各有各的判题沙箱)
docker load -i /root/OJDeploy/oj2-judge-2.tar
```
忘了这一步,`deploy.sh` 起栈时会去 pull 一个不存在的镜像并失败(响亮地失败,
不会静默降级)。构建、回滚和工具链版本表见 `docker/judge/README.md`
### 迁移在起栈之前跑 ### 迁移在起栈之前跑
`deploy.sh` 在「构建镜像」之后、「起栈」之前跑 `oj2-api migrate`,失败就中止部署 `deploy.sh` 在「构建镜像」之后、「起栈」之前跑 `oj2-api migrate`,失败就中止部署
+1 -1
View File
@@ -13,7 +13,7 @@
"db:generate": "bun run --filter '@oj2/api' db:generate", "db:generate": "bun run --filter '@oj2/api' db:generate",
"db:migrate": "bun run --filter '@oj2/api' db:migrate", "db:migrate": "bun run --filter '@oj2/api' db:migrate",
"db:down": "docker compose -f docker/compose.dev.yml down", "db:down": "docker compose -f docker/compose.dev.yml down",
"fmt": "prettier --write apps/api/src apps/web/src apps/web/tests packages/contract/src apps/api/drizzle.config.ts apps/web/vite.config.ts" "fmt": "prettier --write apps/api/src apps/web/src apps/web/tests packages/contract/src apps/api/drizzle.config.ts apps/web/vite.config.ts docker/judge/smoke.ts"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.9.6", "prettier": "^3.9.6",
+2
View File
@@ -19,6 +19,8 @@ export const metricsSchema = z.object({
now: z.string(), now: z.string(),
latest: z.string(), latest: z.string(),
first: z.string(), first: z.string(),
/** 有提交的日历天数(东八区),不是首末提交之间跨了多少天 */
activeDays: z.number().int(),
}) })
export const rankProfileSchema = z.object({ export const rankProfileSchema = z.object({
+110 -3
View File
@@ -114,14 +114,119 @@ export const aiAnalysisRequestSchema = z.object({
/** /**
* AI * AI
* POST /ai/hint 3 * POST /ai/hint
* *
* *
* *
*
* **2c 3 1** 2.6
* L0 L0
* 3
*/ */
export const HINT_MIN_FAILURES = 3 export const HINT_MIN_FAILURES = 1
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) }) /**
* AI AI OJ 2.2**`ai_hint.level`**
*
*
*
* 02 ****
* 2.6L3 / L4 2d
*/
export const HINT_LEVELS = [
{ level: 0, name: "反问", summary: "只反问,不给结论" },
{ level: 1, name: "定位", summary: "只说问题在哪,不说为什么" },
{ level: 2, name: "概念", summary: "讲清涉及的概念,不给改法" },
] as const
export const HINT_MAX_LEVEL = 2
/**
* **** SQL `level >= 0`
*
*
*
* HINT_MIN_FAILURES
*
* `ai_hint.level` null2c 线
*/
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)
return item ? `L${item.level} ${item.name}` : `L${level}`
}
/**
* `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() })
export const classAnalysisRequestSchema = z.object({ export const classAnalysisRequestSchema = z.object({
comparison: z.record(z.string(), z.unknown()), comparison: z.record(z.string(), z.unknown()),
@@ -181,6 +286,8 @@ export type LoginSummary = z.infer<typeof loginSummarySchema>
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema> export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
export type AiHintRequest = z.infer<typeof aiHintRequestSchema> 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 ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
export type ClassPkAnalysisRequest = z.infer< export type ClassPkAnalysisRequest = z.infer<
typeof classPkAnalysisRequestSchema typeof classPkAnalysisRequestSchema
+1
View File
@@ -8,6 +8,7 @@ export * from "./common"
export * from "./content" export * from "./content"
export * from "./contest" export * from "./contest"
export * from "./flowchart" export * from "./flowchart"
export * from "./judge-status"
export * from "./language" export * from "./language"
export * from "./problem" export * from "./problem"
export * from "./problemset" export * from "./problemset"
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod"
/**
* ****
*
* 12 `submission.result`
* `judge/status.ts`
* `utils/constants.ts` `SubmissionStatus`
*/
export const JudgeStatus = {
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
// 同名的类型:原来契约里就有 `type JudgeStatus`(各处按类型引用),值与类型同名合并
export type JudgeStatus = JudgeStatusValue
export const judgeStatusSchema = z.literal(
Object.values(JudgeStatus) as [JudgeStatusValue, ...JudgeStatusValue[]],
)
+37 -4
View File
@@ -8,14 +8,24 @@ import { z } from "zod"
* `Record<string, …>` * `Record<string, …>`
* **** languages.ts * **** languages.ts
* *
* 6 **** `Python2` * ****
* 3 Python2 * `C` / `C++` / `Python``languages.ts`
* SQL`Java`(44 ) / `Golang`(15) / `JavaScript`(3)
*
*
*
* Java / JavaScript / Golang 2026-09
* `apps/api/src/judge/languages.ts`
*
* `Python2` / `Python3` **** 0019
* 104530 937 1235 `Python`
* localStorage
* `normalizeLanguage()` parse
*/ */
export const judgeLanguageSchema = z.enum([ export const judgeLanguageSchema = z.enum([
"Python2",
"Python3",
"C", "C",
"C++", "C++",
"Python",
"Java", "Java",
"JavaScript", "JavaScript",
"Golang", "Golang",
@@ -40,3 +50,26 @@ export const problemLanguageSchema = z.enum([
export type JudgeLanguage = z.infer<typeof judgeLanguageSchema> export type JudgeLanguage = z.infer<typeof judgeLanguageSchema>
export type ProblemLanguage = z.infer<typeof problemLanguageSchema> export type ProblemLanguage = z.infer<typeof problemLanguageSchema>
/**
* ****0019
*
*
* 1. localStorage `Python3`线
* 2. BullMQ
* 3. `Python` ****
*
*/
const LANGUAGE_ALIASES: Record<string, ProblemLanguage> = {
Python2: "Python",
Python3: "Python",
}
/** 把可能是旧值的语言名归一化;认不出来返回 null,由调用方决定怎么兜底。 */
export function normalizeLanguage(value: unknown): ProblemLanguage | null {
if (typeof value !== "string") return null
const parsed = problemLanguageSchema.safeParse(
LANGUAGE_ALIASES[value] ?? value,
)
return parsed.success ? parsed.data : null
}
+3 -3
View File
@@ -106,7 +106,7 @@ export const astRuleSchema = z.object({
max: z.number().int().optional(), max: z.number().int().optional(),
}) })
/** 按语言分组:`{ Python3: [...], C: [...] }`,键是 languages 里的语言名 */ /** 按语言分组:`{ Python: [...], C: [...] }`,键是 languages 里的语言名 */
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema)) export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/** /**
@@ -183,7 +183,7 @@ export const AST_NODE_TARGETS_BY_LANGUAGE: Record<
lambda: { label: "lambda 表达式", node: "lambda_expression" }, lambda: { label: "lambda 表达式", node: "lambda_expression" },
using: { label: "using 声明", node: "using_declaration" }, using: { label: "using 声明", node: "using_declaration" },
}, },
Python3: { Python: {
for_loop: { label: "for 循环", node: "for_statement" }, for_loop: { label: "for 循环", node: "for_statement" },
while_loop: { label: "while 循环", node: "while_statement" }, while_loop: { label: "while 循环", node: "while_statement" },
if_statement: { label: "if 条件", node: "if_statement" }, if_statement: { label: "if 条件", node: "if_statement" },
@@ -260,7 +260,7 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<
C: C_OPERATOR_TARGETS, C: C_OPERATOR_TARGETS,
// `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token) // `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token)
"C++": { ...C_OPERATOR_TARGETS, "<<": "<<", ">>": ">>" }, "C++": { ...C_OPERATOR_TARGETS, "<<": "<<", ">>": ">>" },
Python3: { Python: {
"+": "+", "+": "+",
"-": "-", "-": "-",
"*": "*", "*": "*",
+39 -16
View File
@@ -1,23 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema } from "./common" import { paginatedSchema } from "./common"
import { judgeStatusSchema, type JudgeStatus } from "./judge-status"
import { problemLanguageSchema } from "./language" import { problemLanguageSchema } from "./language"
export const judgeStatusSchema = z.union([
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
z.literal(10),
])
/** /**
* `submission.info` JSONB **** * `submission.info` JSONB ****
* *
@@ -93,6 +79,36 @@ export const statisticInfoSchema = z.looseObject({
.optional(), .optional(),
}) })
/**
* `submission_trace`****
*
*
*
*
* **** AC
* `since_prev_ms`
*/
export const submissionTraceSchema = z.object({
/** 活跃编辑时长:相邻两次编辑间隔不超过 60 秒才累加,页面不可见时不计 */
activeMs: z.number().int().min(0).max(1e8),
/** 打开这道题(或上次提交)到这次提交的墙钟时长 */
sinceOpenMs: z.number().int().min(0).max(1e9),
/** 键入、输入法上屏、补全插入的字符数 */
typedChars: z.number().int().min(0).max(1e7),
/** 粘贴、从外部拖入的字符数 */
pastedChars: z.number().int().min(0).max(1e7),
pasteCount: z.number().int().min(0).max(1e5),
/** 单次最大粘贴的字符数 */
maxPaste: z.number().int().min(0).max(1e7),
deletedChars: z.number().int().min(0).max(1e7),
/** 页面切到后台的次数(切标签页、切窗口、最小化)。只作辅助,别单独拿来说事 */
blurCount: z.number().int().min(0).max(1e5),
/** 这一段开始时编辑器里已有的字符数(本地草稿 / 模板 / 上次提交后的代码) */
initialLen: z.number().int().min(0).max(1e7),
/** 提交时这道题正在课堂协作中。老师替学生交的那条也会是 true,统计时要排掉 */
collab: z.boolean(),
})
export const createSubmissionRequestSchema = z.object({ export const createSubmissionRequestSchema = z.object({
problemId: z.number().int().positive(), problemId: z.number().int().positive(),
/** /**
@@ -116,6 +132,13 @@ export const createSubmissionRequestSchema = z.object({
* *
*/ */
problemSetId: z.number().int().positive().optional(), problemSetId: z.number().int().positive().optional(),
/**
* submissionTraceSchema****`.catch`
* safeParse 400
*
*
*/
trace: submissionTraceSchema.optional().catch(undefined),
}) })
export const createSubmissionResponseSchema = z.object({ export const createSubmissionResponseSchema = z.object({
@@ -397,8 +420,8 @@ export const formatCodeRequestSchema = z.object({
export const formatCodeResponseSchema = z.object({ code: z.string() }) export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type StatisticInfo = z.infer<typeof statisticInfoSchema> export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.infer< export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema typeof createSubmissionRequestSchema
> >