提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是 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>
This commit is contained in:
@@ -114,14 +114,70 @@ export const aiAnalysisRequestSchema = z.object({
|
||||
|
||||
/**
|
||||
* 解锁「让 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`)**,
|
||||
* 和判题状态码一样只能新增、不能改已有含义 —— 教师端「依赖提示」这类风险标签
|
||||
* 要按它聚合,改含义等于把历史数据一起改了。
|
||||
*
|
||||
* 0~2 是一条阶梯,只能逐级上升:**一条新的失败提交最多解锁一级**,而且要学生自己
|
||||
* 点「再多一点提示」才升,不会自动往上爬(2.6)。L3 思路 / 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` 还有第三种值 null:2c 上线之前那批不分级的提示,同样不参与阶梯。
|
||||
*/
|
||||
export const HINT_LEVEL_COMPILE = -1
|
||||
|
||||
/** 给界面看的等级名。编译失败那一档不叫 L-1 */
|
||||
export function hintLevelLabel(level: number) {
|
||||
if (level === HINT_LEVEL_COMPILE) return "编译错误"
|
||||
const item = HINT_LEVELS.find((entry) => entry.level === level)
|
||||
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`),
|
||||
@@ -230,6 +286,7 @@ export type LoginSummary = z.infer<typeof loginSummarySchema>
|
||||
|
||||
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
|
||||
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
|
||||
export type AiHintDone = z.infer<typeof aiHintDoneSchema>
|
||||
export type AiHintFeedbackRequest = z.infer<typeof aiHintFeedbackRequestSchema>
|
||||
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
|
||||
export type ClassPkAnalysisRequest = z.infer<
|
||||
|
||||
Reference in New Issue
Block a user