Files
OJ2/apps/api/src/services/hint-level.ts
T
xuyueandClaude Opus 5 954797a9a5
Deploy / deploy (push) Canceled after 0s
feat(AI 提示): 提示分级(L0 反问 / L1 定位 / L2 概念)与输出后过滤
提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是
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

137 lines
5.9 KiB
TypeScript

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 }
}